feat(viewer): Add data source dialog
Introduce a dialog for switching between built-in content and a user-selected local folder. Persist the folder handle in IndexedDB so the selection survives page refreshes. Scan the folder for .md, .yarn, and .csv files to build the file index. Update App and Sidebar to pass file tree and heading data, and add TypeScript declarations for the File System Access API.
This commit is contained in:
parent
a05b0f4291
commit
53c8ce0677
44
src/App.tsx
44
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";
|
import { useLocation } from "@solidjs/router";
|
||||||
|
|
||||||
// 导入组件以注册自定义元素
|
// 导入组件以注册自定义元素
|
||||||
|
|
@ -8,12 +8,38 @@ import {
|
||||||
MobileSidebar,
|
MobileSidebar,
|
||||||
DesktopSidebar,
|
DesktopSidebar,
|
||||||
DocDialog,
|
DocDialog,
|
||||||
|
DataSourceDialog,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||||
|
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||||
|
|
||||||
|
// Sidebar and TOC data — reload when source changes
|
||||||
|
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||||
|
const [pathHeadings, setPathHeadings] = createSignal<
|
||||||
|
Record<string, TocNode[]>
|
||||||
|
>({});
|
||||||
|
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(() => {
|
const currentPath = createMemo(() => {
|
||||||
// 根据路由加载对应的 markdown 文件
|
// 根据路由加载对应的 markdown 文件
|
||||||
|
|
@ -29,11 +55,13 @@ const App: Component = () => {
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
{/* 桌面端固定侧边栏 */}
|
{/* 桌面端固定侧边栏 */}
|
||||||
<DesktopSidebar />
|
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
||||||
{/* 移动端抽屉式侧边栏 */}
|
{/* 移动端抽屉式侧边栏 */}
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
isOpen={isSidebarOpen()}
|
isOpen={isSidebarOpen()}
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
|
fileTree={fileTree()}
|
||||||
|
pathHeadings={pathHeadings()}
|
||||||
/>
|
/>
|
||||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
|
|
@ -47,6 +75,13 @@ const App: Component = () => {
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDataSourceOpen(true)}
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
|
||||||
|
title="Content Source"
|
||||||
|
>
|
||||||
|
📂
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsDocOpen(true)}
|
onClick={() => setIsDocOpen(true)}
|
||||||
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
||||||
|
|
@ -63,6 +98,11 @@ const App: Component = () => {
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||||
|
<DataSourceDialog
|
||||||
|
isOpen={isDataSourceOpen()}
|
||||||
|
onClose={() => setIsDataSourceOpen(false)}
|
||||||
|
onSourceChanged={handleSourceChanged}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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<DataSourceDialogProps> = (props) => {
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [folderPath, setFolderPath] = createSignal<string | null>(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 (
|
||||||
|
<Show when={props.isOpen}>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 mb-4">Content Source</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{/* Built-in option */}
|
||||||
|
<button
|
||||||
|
onClick={handleBuiltIn}
|
||||||
|
disabled={loading()}
|
||||||
|
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||||
|
classList={{
|
||||||
|
"border-blue-500 bg-blue-50":
|
||||||
|
currentSource() === "cli" || currentSource() === "webpack",
|
||||||
|
"border-gray-200 hover:border-gray-300":
|
||||||
|
currentSource() !== "cli" && currentSource() !== "webpack",
|
||||||
|
"opacity-50 cursor-not-allowed": loading(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="font-medium text-gray-900">Built-in Content</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
Use the bundled content files shipped with the app.
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
currentSource() === "cli" || currentSource() === "webpack"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="text-xs text-blue-600 mt-1 font-medium">Active</div>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Folder picker option */}
|
||||||
|
<button
|
||||||
|
onClick={handleFolderPick}
|
||||||
|
disabled={
|
||||||
|
loading() ||
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!("showDirectoryPicker" in window)
|
||||||
|
}
|
||||||
|
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||||
|
classList={{
|
||||||
|
"border-blue-500 bg-blue-50": currentSource() === "folder",
|
||||||
|
"border-gray-200 hover:border-gray-300":
|
||||||
|
currentSource() !== "folder",
|
||||||
|
"opacity-50 cursor-not-allowed":
|
||||||
|
loading() || !("showDirectoryPicker" in window),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="font-medium text-gray-900">Local Folder</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
Pick a folder on your computer. Supports .md, .yarn, and .csv
|
||||||
|
files.
|
||||||
|
{"showDirectoryPicker" in window
|
||||||
|
? " Will be remembered across sessions."
|
||||||
|
: " (Not supported in this browser)"}
|
||||||
|
</div>
|
||||||
|
<Show when={currentSource() === "folder"}>
|
||||||
|
<div class="text-xs text-blue-600 mt-1 font-medium">
|
||||||
|
Active{folderPath() ? ` — ${folderPath()}` : ""}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataSourceDialog;
|
||||||
|
|
@ -6,6 +6,8 @@ import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
fileTree?: FileNode[];
|
||||||
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarContentProps {
|
interface SidebarContentProps {
|
||||||
|
|
@ -25,7 +27,9 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
// 响应式获取当前文件的标题列表
|
// 响应式获取当前文件的标题列表
|
||||||
const currentFileHeadings = createMemo(() => {
|
const currentFileHeadings = createMemo(() => {
|
||||||
const pathname = decodeURIComponent(location.pathname);
|
const pathname = decodeURIComponent(location.pathname);
|
||||||
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
return (
|
||||||
|
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -68,11 +72,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
本页
|
本页
|
||||||
</h3>
|
</h3>
|
||||||
{currentFileHeadings().map((node) => (
|
{currentFileHeadings().map((node) => (
|
||||||
<HeadingNode
|
<HeadingNode node={node} basePath={location.pathname} depth={0} />
|
||||||
node={node}
|
|
||||||
basePath={location.pathname}
|
|
||||||
depth={0}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -85,14 +85,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||||
*/
|
*/
|
||||||
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||||
|
Record<string, TocNode[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 加载目录数据
|
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||||
|
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||||
|
|
||||||
|
// 加载目录数据 (only if props not provided)
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (props.fileTree && props.pathHeadings) return;
|
||||||
const toc = await generateToc();
|
const toc = await generateToc();
|
||||||
setFileTree(toc.fileTree);
|
setSelfFileTree(toc.fileTree);
|
||||||
setPathHeadings(toc.pathHeadings);
|
setSelfPathHeadings(toc.pathHeadings);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,16 +130,24 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||||
/**
|
/**
|
||||||
* 桌面端固定侧边栏
|
* 桌面端固定侧边栏
|
||||||
*/
|
*/
|
||||||
export const DesktopSidebar: Component<{}> = () => {
|
export const DesktopSidebar: Component<{
|
||||||
|
fileTree?: FileNode[];
|
||||||
|
pathHeadings?: Record<string, TocNode[]>;
|
||||||
|
}> = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||||
|
Record<string, TocNode[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||||
|
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||||
|
|
||||||
// 加载目录数据
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (props.fileTree && props.pathHeadings) return;
|
||||||
const toc = await generateToc();
|
const toc = await generateToc();
|
||||||
setFileTree(toc.fileTree);
|
setSelfFileTree(toc.fileTree);
|
||||||
setPathHeadings(toc.pathHeadings);
|
setSelfPathHeadings(toc.pathHeadings);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export type { SidebarProps } from "./Sidebar";
|
||||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||||
export { default as DocDialog } from "./DocDialog";
|
export { default as DocDialog } from "./DocDialog";
|
||||||
export type { DocDialogProps } from "./DocDialog";
|
export type { DocDialogProps } from "./DocDialog";
|
||||||
|
export { default as DataSourceDialog } from "./DataSourceDialog";
|
||||||
|
export type { DataSourceDialogProps } from "./DataSourceDialog";
|
||||||
|
|
||||||
// 导出数据类型
|
// 导出数据类型
|
||||||
export type { DiceProps } from "./md-dice";
|
export type { DiceProps } from "./md-dice";
|
||||||
|
|
|
||||||
|
|
@ -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<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
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<void> {
|
||||||
|
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<FileSystemDirectoryHandle | null> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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";
|
||||||
|
}
|
||||||
|
|
@ -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<string, string>;
|
type FileIndex = Record<string, string>;
|
||||||
|
|
||||||
let fileIndex: FileIndex | null = null;
|
let fileIndex: FileIndex | null = null;
|
||||||
let indexLoadPromise: Promise<void> | null = null;
|
let indexLoadPromise: Promise<void> | 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<void> {
|
function ensureIndexLoaded(): Promise<void> {
|
||||||
if (indexLoadPromise) return indexLoadPromise;
|
if (indexLoadPromise) return indexLoadPromise;
|
||||||
|
|
||||||
indexLoadPromise = (async () => {
|
indexLoadPromise = (async () => {
|
||||||
// 尝试 CLI 环境:从 /__CONTENT_INDEX.json 加载
|
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/__CONTENT_INDEX.json");
|
const response = await fetch("/__CONTENT_INDEX.json");
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const index = await response.json();
|
const index = await response.json();
|
||||||
fileIndex = { ...fileIndex, ...index };
|
fileIndex = { ...fileIndex, ...index };
|
||||||
|
activeSource = "cli";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// CLI 索引不可用时尝试 dev 环境
|
// CLI 索引不可用时尝试下一个策略
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev 环境:使用 import.meta.webpackContext + raw loader 加载 .md, .csv, .yarn 文件
|
// 策略 2: Dev 环境 — webpackContext + raw loader
|
||||||
try {
|
try {
|
||||||
const context = import.meta.webpackContext("../../content", {
|
const context = import.meta.webpackContext("../../content", {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|
@ -37,21 +53,143 @@ function ensureIndexLoaded(): Promise<void> {
|
||||||
const keys = context.keys();
|
const keys = context.keys();
|
||||||
const index: FileIndex = {};
|
const index: FileIndex = {};
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// context 返回的是模块,需要访问其 default 导出(raw-loader 处理后的内容)
|
|
||||||
const module = context(key) as { default?: string } | string;
|
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);
|
const normalizedPath = "/content" + key.slice(1);
|
||||||
index[normalizedPath] = content;
|
index[normalizedPath] = content;
|
||||||
}
|
}
|
||||||
fileIndex = { ...fileIndex, ...index };
|
fileIndex = { ...fileIndex, ...index };
|
||||||
|
activeSource = "webpack";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// webpackContext 不可用时忽略
|
// webpackContext 不可用时忽略
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
|
||||||
|
if (!activeSource) {
|
||||||
|
try {
|
||||||
|
const restored = await restoreSavedHandle();
|
||||||
|
if (restored) {
|
||||||
|
activeSource = "folder";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 无法恢复,继续 — 用户需要手动选择文件夹
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return indexLoadPromise;
|
return indexLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描文件系统目录,构建文件索引
|
||||||
|
* 递归扫描,仅索引 .md / .yarn / .csv 文件
|
||||||
|
*/
|
||||||
|
async function scanDirectory(
|
||||||
|
handle: FileSystemDirectoryHandle,
|
||||||
|
prefix = "",
|
||||||
|
): Promise<FileIndex> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string[] | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
const content = await res.text();
|
const content = await res.text();
|
||||||
fileIndex = fileIndex || {};
|
fileIndex = fileIndex || {};
|
||||||
fileIndex[path] = content;
|
fileIndex[path] = content;
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,8 +212,8 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
|
||||||
await ensureIndexLoaded();
|
await ensureIndexLoaded();
|
||||||
if (!fileIndex) return [];
|
if (!fileIndex) return [];
|
||||||
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
|
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
|
||||||
return Object.keys(fileIndex).filter(path =>
|
return Object.keys(fileIndex).filter((path) =>
|
||||||
path.toLowerCase().endsWith(normalizedExt.toLowerCase())
|
path.toLowerCase().endsWith(normalizedExt.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,4 +223,6 @@ export async function getPathsByExtension(ext: string): Promise<string[]> {
|
||||||
export function clearIndex(): void {
|
export function clearIndex(): void {
|
||||||
fileIndex = null;
|
fileIndex = null;
|
||||||
indexLoadPromise = null;
|
indexLoadPromise = null;
|
||||||
|
activeSource = null;
|
||||||
|
activeDirHandle = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,40 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// 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<PermissionState>;
|
||||||
|
requestPermission?(
|
||||||
|
descriptor?: FileSystemHandlePermissionDescriptor,
|
||||||
|
): Promise<PermissionState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemFileHandle {
|
||||||
|
kind: "file";
|
||||||
|
name: string;
|
||||||
|
getFile(): Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSystemHandle = FileSystemDirectoryHandle | FileSystemFileHandle;
|
||||||
|
|
||||||
|
interface DirectoryPickerOptions {
|
||||||
|
mode?: "read" | "readwrite";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
showDirectoryPicker(
|
||||||
|
options?: DirectoryPickerOptions,
|
||||||
|
): Promise<FileSystemDirectoryHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
interface WebpackContext {
|
interface WebpackContext {
|
||||||
(path: string): { default?: string } | string;
|
(path: string): { default?: string } | string;
|
||||||
keys(): string[];
|
keys(): string[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue