diff --git a/src/App.tsx b/src/App.tsx index 7ce3a79..f3f80ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, createSignal } from "solid-js"; +import { Component, createEffect, createMemo, createSignal } from "solid-js"; import { useLocation } from "@solidjs/router"; // 导入组件以注册自定义元素 @@ -8,12 +8,38 @@ import { MobileSidebar, DesktopSidebar, DocDialog, + DataSourceDialog, } from "./components"; +import { generateToc, type FileNode, type TocNode } from "./data-loader"; const App: Component = () => { const location = useLocation(); const [isSidebarOpen, setIsSidebarOpen] = createSignal(false); const [isDocOpen, setIsDocOpen] = createSignal(false); + const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false); + + // Sidebar and TOC data — reload when source changes + const [fileTree, setFileTree] = createSignal([]); + const [pathHeadings, setPathHeadings] = createSignal< + Record + >({}); + const [tocKey, setTocKey] = createSignal(0); + + const loadToc = async () => { + const toc = await generateToc(); + setFileTree(toc.fileTree); + setPathHeadings(toc.pathHeadings); + }; + + // Load TOC on mount and when source changes + createEffect(() => { + void tocKey(); + void loadToc(); + }); + + const handleSourceChanged = () => { + setTocKey((k) => k + 1); + }; const currentPath = createMemo(() => { // 根据路由加载对应的 markdown 文件 @@ -29,11 +55,13 @@ const App: Component = () => { return (
{/* 桌面端固定侧边栏 */} - + {/* 移动端抽屉式侧边栏 */} setIsSidebarOpen(false)} + fileTree={fileTree()} + pathHeadings={pathHeadings()} />
@@ -47,6 +75,13 @@ const App: Component = () => {

TTRPG Tools

+
setIsDocOpen(false)} /> + setIsDataSourceOpen(false)} + onSourceChanged={handleSourceChanged} + />
); }; diff --git a/src/components/DataSourceDialog.tsx b/src/components/DataSourceDialog.tsx new file mode 100644 index 0000000..bd69360 --- /dev/null +++ b/src/components/DataSourceDialog.tsx @@ -0,0 +1,159 @@ +import { Component, createSignal, Show } from "solid-js"; +import { + loadFromUserFolder, + switchToBuiltInSource, + getActiveSource, + clearIndex, +} from "../data-loader/file-index"; + +export interface DataSourceDialogProps { + isOpen: boolean; + onClose: () => void; + onSourceChanged: () => void; +} + +/** + * Dialog for choosing how to source content files. + * Supports: + * - Built-in content (CLI / webpack bundled files) + * - Local folder (via File System Access API) + */ +export const DataSourceDialog: Component = (props) => { + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + const [folderPath, setFolderPath] = createSignal(null); + + const currentSource = () => getActiveSource(); + + const handleFolderPick = async () => { + setLoading(true); + setError(null); + + const paths = await loadFromUserFolder(); + + if (paths === null) { + // User cancelled or API not supported — don't show error for cancellation + setLoading(false); + return; + } + + if (paths.length === 0) { + setError( + "No supported files (.md, .yarn, .csv) found in the selected folder.", + ); + setLoading(false); + return; + } + + setFolderPath(`${paths.length} files loaded`); + setLoading(false); + props.onSourceChanged(); + props.onClose(); + }; + + const handleBuiltIn = async () => { + setLoading(true); + setError(null); + + await switchToBuiltInSource(); + // Force re-index from built-in sources + clearIndex(); + + setLoading(false); + props.onSourceChanged(); + props.onClose(); + }; + + return ( + +
+
e.stopPropagation()} + > +

Content Source

+ +
+ {/* Built-in option */} + + + {/* Folder picker option */} + +
+ + +
+ {error()} +
+
+ +
+ +
+
+
+
+ ); +}; + +export default DataSourceDialog; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 3f5a713..32cf7ee 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,6 +6,8 @@ import { FileTreeNode, HeadingNode } from "./FileTree"; export interface SidebarProps { isOpen: boolean; onClose: () => void; + fileTree?: FileNode[]; + pathHeadings?: Record; } interface SidebarContentProps { @@ -25,7 +27,9 @@ const SidebarContent: Component = (props) => { // 响应式获取当前文件的标题列表 const currentFileHeadings = createMemo(() => { const pathname = decodeURIComponent(location.pathname); - return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []; + return ( + props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [] + ); }); return ( @@ -68,11 +72,7 @@ const SidebarContent: Component = (props) => { 本页 {currentFileHeadings().map((node) => ( - + ))}
@@ -85,14 +85,20 @@ const SidebarContent: Component = (props) => { */ export const MobileSidebar: Component = (props) => { const location = useLocation(); - const [fileTree, setFileTree] = createSignal([]); - const [pathHeadings, setPathHeadings] = createSignal>({}); + const [selfFileTree, setSelfFileTree] = createSignal([]); + const [selfPathHeadings, setSelfPathHeadings] = createSignal< + Record + >({}); - // 加载目录数据 + const fileTree = () => props.fileTree ?? selfFileTree(); + const pathHeadings = () => props.pathHeadings ?? selfPathHeadings(); + + // 加载目录数据 (only if props not provided) onMount(async () => { + if (props.fileTree && props.pathHeadings) return; const toc = await generateToc(); - setFileTree(toc.fileTree); - setPathHeadings(toc.pathHeadings); + setSelfFileTree(toc.fileTree); + setSelfPathHeadings(toc.pathHeadings); }); return ( @@ -124,16 +130,24 @@ export const MobileSidebar: Component = (props) => { /** * 桌面端固定侧边栏 */ -export const DesktopSidebar: Component<{}> = () => { +export const DesktopSidebar: Component<{ + fileTree?: FileNode[]; + pathHeadings?: Record; +}> = (props) => { const location = useLocation(); - const [fileTree, setFileTree] = createSignal([]); - const [pathHeadings, setPathHeadings] = createSignal>({}); + const [selfFileTree, setSelfFileTree] = createSignal([]); + const [selfPathHeadings, setSelfPathHeadings] = createSignal< + Record + >({}); + + const fileTree = () => props.fileTree ?? selfFileTree(); + const pathHeadings = () => props.pathHeadings ?? selfPathHeadings(); - // 加载目录数据 onMount(async () => { + if (props.fileTree && props.pathHeadings) return; const toc = await generateToc(); - setFileTree(toc.fileTree); - setPathHeadings(toc.pathHeadings); + setSelfFileTree(toc.fileTree); + setSelfPathHeadings(toc.pathHeadings); }); return ( diff --git a/src/components/index.ts b/src/components/index.ts index ddee828..da79502 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -21,6 +21,8 @@ export type { SidebarProps } from "./Sidebar"; export { FileTreeNode, HeadingNode } from "./FileTree"; export { default as DocDialog } from "./DocDialog"; export type { DocDialogProps } from "./DocDialog"; +export { default as DataSourceDialog } from "./DataSourceDialog"; +export type { DataSourceDialogProps } from "./DataSourceDialog"; // 导出数据类型 export type { DiceProps } from "./md-dice"; diff --git a/src/data-loader/file-index-db.ts b/src/data-loader/file-index-db.ts new file mode 100644 index 0000000..d7f9cf8 --- /dev/null +++ b/src/data-loader/file-index-db.ts @@ -0,0 +1,104 @@ +/** + * IndexedDB helper for persisting FileSystemDirectoryHandle + * across page refreshes and browsing sessions. + */ + +const DB_NAME = "ttrpg-file-index"; +const DB_VERSION = 1; +const STORE_NAME = "handles"; +const HANDLE_KEY = "rootDir"; + +let dbPromise: Promise | null = null; + +function openDB(): Promise { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + if (typeof indexedDB === "undefined") { + return reject(new Error("IndexedDB not available")); + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + return dbPromise; +} + +/** + * Persist a FileSystemDirectoryHandle to IndexedDB. + * Uses structured clone (natively supported by IndexedDB for FileSystemHandle). + */ +export async function saveHandle(handle: FileSystemDirectoryHandle): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const txn = db.transaction(STORE_NAME, "readwrite"); + const store = txn.objectStore(STORE_NAME); + const req = store.put(handle, HANDLE_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +/** + * Try to load a previously persisted FileSystemDirectoryHandle. + * Returns null if none was saved, or if the stored handle cannot be used. + */ +export async function loadHandle(): Promise { + try { + const db = await openDB(); + return new Promise((resolve) => { + const txn = db.transaction(STORE_NAME, "readonly"); + const store = txn.objectStore(STORE_NAME); + const req = store.get(HANDLE_KEY); + req.onsuccess = () => resolve(req.result ?? null); + req.onerror = () => resolve(null); + }); + } catch { + return null; + } +} + +/** + * Remove the persisted handle (e.g., user switches source). + */ +export async function removeHandle(): Promise { + try { + const db = await openDB(); + return new Promise((resolve) => { + const txn = db.transaction(STORE_NAME, "readwrite"); + const store = txn.objectStore(STORE_NAME); + const req = store.delete(HANDLE_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + }); + } catch { + // ignore + } +} + +/** + * Check if a stored handle still has read permission, + * or request it with a user gesture. + */ +export async function ensurePermission( + handle: FileSystemDirectoryHandle, +): Promise { + // FileSystemHandle.queryPermission not in all type defs + const queryOpts = { mode: "read" as const }; + const current = await (handle as any).queryPermission?.(queryOpts); + + if (current === "granted") return true; + + const requested = await (handle as any).requestPermission?.(queryOpts); + return requested === "granted"; +} diff --git a/src/data-loader/file-index.ts b/src/data-loader/file-index.ts index 2e05e10..79a99c7 100644 --- a/src/data-loader/file-index.ts +++ b/src/data-loader/file-index.ts @@ -1,34 +1,50 @@ /** * 文件索引管理器 - * 支持任意文件类型的索引加载和缓存 + * 支持多种文件索引加载方式: + * 1. CLI 环境:从 /__CONTENT_INDEX.json 加载 + * 2. Dev 环境:使用 webpackContext 仅加载 .md 文件 + * 3. 浏览器环境:用户选择本地文件夹,扫描文件索引 + * - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择 */ +import { + saveHandle, + loadHandle, + removeHandle, + ensurePermission, +} from "./file-index-db"; + type FileIndex = Record; let fileIndex: FileIndex | null = null; let indexLoadPromise: Promise | null = null; +let activeSource: "cli" | "webpack" | "folder" | null = null; + +/** Currently active directory handle (if folder source) */ +let activeDirHandle: FileSystemDirectoryHandle | null = null; /** * 加载文件索引(只加载一次) - * 支持 CLI 环境(从 JSON 加载)和 Dev 环境(使用 webpackContext 仅加载 .md 文件) + * 尝试顺序:CLI JSON → webpack context → 已持久化的目录句柄 */ function ensureIndexLoaded(): Promise { if (indexLoadPromise) return indexLoadPromise; indexLoadPromise = (async () => { - // 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载 + // 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载 try { const response = await fetch("/__CONTENT_INDEX.json"); if (response.ok) { const index = await response.json(); fileIndex = { ...fileIndex, ...index }; + activeSource = "cli"; return; } } catch (e) { - // CLI 索引不可用时尝试 dev 环境 + // CLI 索引不可用时尝试下一个策略 } - // Dev 环境:使用 import.meta.webpackContext + raw loader 加载 .md, .csv, .yarn 文件 + // 策略 2: Dev 环境 — webpackContext + raw loader try { const context = import.meta.webpackContext("../../content", { recursive: true, @@ -37,21 +53,143 @@ function ensureIndexLoaded(): Promise { const keys = context.keys(); const index: FileIndex = {}; for (const key of keys) { - // context 返回的是模块,需要访问其 default 导出(raw-loader 处理后的内容) const module = context(key) as { default?: string } | string; - const content = typeof module === "string" ? module : module.default ?? ""; + const content = + typeof module === "string" ? module : (module.default ?? ""); const normalizedPath = "/content" + key.slice(1); index[normalizedPath] = content; } fileIndex = { ...fileIndex, ...index }; + activeSource = "webpack"; } catch (e) { // webpackContext 不可用时忽略 } + + // 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄 + if (!activeSource) { + try { + const restored = await restoreSavedHandle(); + if (restored) { + activeSource = "folder"; + return; + } + } catch (e) { + // 无法恢复,继续 — 用户需要手动选择文件夹 + } + } })(); return indexLoadPromise; } +/** + * 扫描文件系统目录,构建文件索引 + * 递归扫描,仅索引 .md / .yarn / .csv 文件 + */ +async function scanDirectory( + handle: FileSystemDirectoryHandle, + prefix = "", +): Promise { + const index: FileIndex = {}; + const acceptedExt = /\.(md|yarn|csv)$/i; + + for await (const [name, entry] of (handle as any).entries()) { + if (entry.kind === "directory") { + const sub = await scanDirectory( + entry as FileSystemDirectoryHandle, + prefix + name + "/", + ); + Object.assign(index, sub); + } else if (entry.kind === "file" && acceptedExt.test(name)) { + const file = await (entry as FileSystemFileHandle).getFile(); + const path = prefix + name; + index[path] = await file.text(); + } + } + + return index; +} + +/** + * 尝试从 IndexedDB 恢复之前保存的目录句柄并加载内容 + */ +async function restoreSavedHandle(): Promise { + if (typeof indexedDB === "undefined") return false; + + const handle = await loadHandle(); + if (!handle) return false; + + const permitted = await ensurePermission(handle); + if (!permitted) return false; + + try { + const index = await scanDirectory(handle); + fileIndex = { ...fileIndex, ...index }; + activeDirHandle = handle; + // Refresh the promise so future calls use new index + indexLoadPromise = Promise.resolve(); + return true; + } catch { + // handle is stale (folder moved/deleted), clear it + await removeHandle(); + return false; + } +} + +/** + * 让用户选择本地文件夹并加载文件索引 (Browser only) + * 会持久化目录句柄到 IndexedDB,刷新后自动恢复。 + * + * @returns 索引中的文件路径数组,失败返回 null + */ +export async function loadFromUserFolder(): Promise { + if (!("showDirectoryPicker" in window)) { + console.warn("showDirectoryPicker not supported in this browser"); + return null; + } + + try { + const handle = await window.showDirectoryPicker({ mode: "read" }); + const index = await scanDirectory(handle); + + // Replace the existing index entirely with the user's folder content + fileIndex = index; + activeDirHandle = handle; + activeSource = "folder"; + // Reset the load promise so future ensureIndexLoaded() calls are no-ops + indexLoadPromise = Promise.resolve(); + + await saveHandle(handle); + + return Object.keys(index); + } catch (err) { + // User cancelled or error + if ((err as DOMException)?.name !== "AbortError") { + console.error("Failed to load folder:", err); + } + return null; + } +} + +/** + * 移除已保存的目录句柄,并清除索引 + * 之后确保索引加载时回退到 CLI/webpack 策略 + */ +export async function switchToBuiltInSource(): Promise { + await removeHandle(); + activeDirHandle = null; + fileIndex = null; + indexLoadPromise = null; + activeSource = null; +} + +/** + * 获取当前激活的内容源 + */ +export function getActiveSource(): string | null { + return activeSource; +} + /** * 从索引获取文件内容 */ @@ -64,7 +202,7 @@ export async function getIndexedData(path: string): Promise { const content = await res.text(); fileIndex = fileIndex || {}; fileIndex[path] = content; - return content; + return content; } /** @@ -74,8 +212,8 @@ export async function getPathsByExtension(ext: string): Promise { await ensureIndexLoaded(); if (!fileIndex) return []; const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`; - return Object.keys(fileIndex).filter(path => - path.toLowerCase().endsWith(normalizedExt.toLowerCase()) + return Object.keys(fileIndex).filter((path) => + path.toLowerCase().endsWith(normalizedExt.toLowerCase()), ); } @@ -85,4 +223,6 @@ export async function getPathsByExtension(ext: string): Promise { export function clearIndex(): void { fileIndex = null; indexLoadPromise = null; + activeSource = null; + activeDirHandle = null; } diff --git a/src/global.d.ts b/src/global.d.ts index 0577850..610abf9 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,5 +1,40 @@ /// +// File System Access API (Chrome / Edge) +interface FileSystemHandlePermissionDescriptor { + mode?: "read" | "readwrite"; +} + +interface FileSystemDirectoryHandle { + kind: "directory"; + name: string; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + queryPermission?( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; + requestPermission?( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; +} + +interface FileSystemFileHandle { + kind: "file"; + name: string; + getFile(): Promise; +} + +type FileSystemHandle = FileSystemDirectoryHandle | FileSystemFileHandle; + +interface DirectoryPickerOptions { + mode?: "read" | "readwrite"; +} + +interface Window { + showDirectoryPicker( + options?: DirectoryPickerOptions, + ): Promise; +} + interface WebpackContext { (path: string): { default?: string } | string; keys(): string[];