wiki-viewer 1.8.2 → 1.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
- package/.next/standalone/.next/static/chunks/0d~o0h0ssgra7.js +129 -0
- package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
- package/.next/standalone/AGENTS.md +104 -0
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
- package/.next/standalone/src/app/page.tsx +580 -192
- package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
- package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
- package/.next/standalone/src/components/editor/editor.tsx +145 -48
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
- package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
- package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
- package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
- package/.next/standalone/src/lib/app-runner.ts +57 -16
- package/.next/standalone/src/stores/pin-store.ts +55 -0
- package/.next/standalone/src/stores/recent-store.ts +50 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
- package/.next/standalone/.next/static/chunks/16r--8~rpehcz.js +0 -129
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ChevronDown, ChevronRight, Link2 } from "lucide-react";
|
|
5
|
+
import { wsFetch } from "@/lib/workspace-client";
|
|
6
|
+
import { useEditorStore } from "@/stores/editor-store";
|
|
7
|
+
|
|
8
|
+
interface BacklinkEntry {
|
|
9
|
+
path: string;
|
|
10
|
+
snippet: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function slugFromPath(filePath: string): string {
|
|
14
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
15
|
+
return base.replace(/\.md$/i, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True if `content` contains an actual wiki-link to `slug`, i.e. `[[slug]]`,
|
|
20
|
+
* `[[slug|alias]]`, or `[[slug#anchor]]`. FTS matches tokenised prose, so we
|
|
21
|
+
* re-check the raw text to drop false positives (a page merely mentioning the
|
|
22
|
+
* word, not linking to it).
|
|
23
|
+
*/
|
|
24
|
+
function hasWikiLinkTo(content: string, slug: string): boolean {
|
|
25
|
+
const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
26
|
+
const re = new RegExp(`\\[\\[${esc}(?:\\|[^\\]#|]+|#[a-z0-9-]+)?\\]\\]`, "i");
|
|
27
|
+
return re.test(content);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Display the last two path segments, e.g. "notes/my-page.md" → "notes / my-page" */
|
|
31
|
+
function displayPath(filePath: string): string {
|
|
32
|
+
const parts = filePath.replace(/\.md$/i, "").split("/");
|
|
33
|
+
return parts.length > 1 ? parts.slice(-2).join(" / ") : parts[0] ?? filePath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BacklinksPanelProps {
|
|
37
|
+
currentPath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
|
|
41
|
+
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
44
|
+
|
|
45
|
+
const slug = slugFromPath(currentPath);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!slug) return;
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setBacklinks([]);
|
|
52
|
+
|
|
53
|
+
// Search for the slug surrounded by [[ ]] — FTS may not match brackets,
|
|
54
|
+
// but we pass the full pattern; backend uses BM25 on tokenised text so
|
|
55
|
+
// results will include pages that mention the slug near [[ ]].
|
|
56
|
+
// FTS is a coarse candidate filter (it tokenises and strips brackets), so
|
|
57
|
+
// we fetch each candidate's raw content and confirm a literal [[slug]] link.
|
|
58
|
+
(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const r = await wsFetch("/api/wiki/search", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ query: slug, limit: 50 }),
|
|
64
|
+
});
|
|
65
|
+
const d: {
|
|
66
|
+
matches?: Array<{ path: string; score: number; snippet: string }>;
|
|
67
|
+
} = r.ok ? await r.json() : { matches: [] };
|
|
68
|
+
|
|
69
|
+
const candidates = (d.matches ?? []).filter(
|
|
70
|
+
(m) => m.path !== currentPath && /\.md$/i.test(m.path),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const confirmed = await Promise.all(
|
|
74
|
+
candidates.map(async (m) => {
|
|
75
|
+
try {
|
|
76
|
+
const cr = await wsFetch(
|
|
77
|
+
`/api/wiki/content?path=${encodeURIComponent(m.path)}`,
|
|
78
|
+
);
|
|
79
|
+
if (!cr.ok) return null;
|
|
80
|
+
const { content } = (await cr.json()) as { content: string };
|
|
81
|
+
if (!hasWikiLinkTo(content, slug)) return null;
|
|
82
|
+
return { path: m.path, snippet: m.snippet } as BacklinkEntry;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (cancelled) return;
|
|
90
|
+
setBacklinks(
|
|
91
|
+
confirmed.filter((b): b is BacklinkEntry => b !== null),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
if (!cancelled) setBacklinks([]);
|
|
95
|
+
} finally {
|
|
96
|
+
if (!cancelled) setLoading(false);
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
};
|
|
103
|
+
}, [currentPath, slug]);
|
|
104
|
+
|
|
105
|
+
// Hide entirely when loading with no prior results or genuinely empty
|
|
106
|
+
if (!loading && backlinks.length === 0) return null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="max-w-[var(--editor-max-w,48rem)] mx-auto px-4 sm:px-8 pb-8 pt-1">
|
|
110
|
+
<div className="border-t border-border/60 pt-4">
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
113
|
+
className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors mb-2"
|
|
114
|
+
aria-expanded={!collapsed}
|
|
115
|
+
>
|
|
116
|
+
{collapsed ? (
|
|
117
|
+
<ChevronRight className="h-3 w-3 shrink-0" />
|
|
118
|
+
) : (
|
|
119
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
120
|
+
)}
|
|
121
|
+
<Link2 className="h-3 w-3 shrink-0" />
|
|
122
|
+
<span className="font-medium">Linked from</span>
|
|
123
|
+
{backlinks.length > 0 && (
|
|
124
|
+
<span className="opacity-50 tabular-nums">({backlinks.length})</span>
|
|
125
|
+
)}
|
|
126
|
+
{loading && <span className="opacity-40 ml-1">…</span>}
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
{!collapsed && backlinks.length > 0 && (
|
|
130
|
+
<ul className="flex flex-col gap-1.5">
|
|
131
|
+
{backlinks.map((bl) => (
|
|
132
|
+
<li key={bl.path} className="flex flex-col">
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => useEditorStore.getState().loadPage(bl.path)}
|
|
135
|
+
className="text-left text-[12px] text-primary/70 hover:text-primary hover:underline underline-offset-2 truncate transition-colors"
|
|
136
|
+
title={bl.path}
|
|
137
|
+
>
|
|
138
|
+
{displayPath(bl.path)}
|
|
139
|
+
</button>
|
|
140
|
+
{bl.snippet && (
|
|
141
|
+
<p className="text-[10.5px] text-muted-foreground/50 line-clamp-1 mt-0.5">
|
|
142
|
+
{bl.snippet}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
</li>
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { Editor } from "@tiptap/react";
|
|
5
|
+
import { List } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
interface Heading {
|
|
9
|
+
level: number;
|
|
10
|
+
text: string;
|
|
11
|
+
pos: number; // ProseMirror doc position (start of node)
|
|
12
|
+
uid: string; // unique id within the doc (text + index dedup)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractHeadings(editor: Editor): Heading[] {
|
|
16
|
+
const headings: Heading[] = [];
|
|
17
|
+
const seen = new Map<string, number>();
|
|
18
|
+
editor.state.doc.forEach((node, pos) => {
|
|
19
|
+
if (node.type.name === "heading") {
|
|
20
|
+
const text = node.textContent;
|
|
21
|
+
const level = node.attrs.level as number;
|
|
22
|
+
const base = text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "") || "heading";
|
|
23
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
24
|
+
seen.set(base, count);
|
|
25
|
+
const uid = count === 1 ? base : `${base}-${count}`;
|
|
26
|
+
headings.push({ level, text, pos, uid });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return headings;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DocumentOutlineProps {
|
|
33
|
+
editor: Editor | null;
|
|
34
|
+
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function DocumentOutline({ editor, scrollContainerRef }: DocumentOutlineProps) {
|
|
38
|
+
const [headings, setHeadings] = useState<Heading[]>([]);
|
|
39
|
+
const [activeUid, setActiveUid] = useState<string | null>(null);
|
|
40
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
41
|
+
const [scrollProgress, setScrollProgress] = useState(0);
|
|
42
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
43
|
+
|
|
44
|
+
// Extract headings on every doc update
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!editor) return;
|
|
47
|
+
const update = () => setHeadings(extractHeadings(editor));
|
|
48
|
+
editor.on("update", update);
|
|
49
|
+
update();
|
|
50
|
+
return () => { editor.off("update", update); };
|
|
51
|
+
}, [editor]);
|
|
52
|
+
|
|
53
|
+
// Reading-progress bar: track scroll on the scroll container
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const el = scrollContainerRef.current;
|
|
56
|
+
if (!el) return;
|
|
57
|
+
let rafId = 0;
|
|
58
|
+
const onScroll = () => {
|
|
59
|
+
cancelAnimationFrame(rafId);
|
|
60
|
+
rafId = requestAnimationFrame(() => {
|
|
61
|
+
const total = el.scrollHeight - el.clientHeight;
|
|
62
|
+
setScrollProgress(total > 0 ? Math.min(1, el.scrollTop / total) : 0);
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
66
|
+
return () => {
|
|
67
|
+
el.removeEventListener("scroll", onScroll);
|
|
68
|
+
cancelAnimationFrame(rafId);
|
|
69
|
+
};
|
|
70
|
+
}, [scrollContainerRef]);
|
|
71
|
+
|
|
72
|
+
// Scroll-spy: observe heading DOM nodes
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
observerRef.current?.disconnect();
|
|
75
|
+
const container = scrollContainerRef.current;
|
|
76
|
+
if (!container || !editor || headings.length === 0) return;
|
|
77
|
+
|
|
78
|
+
const observer = new IntersectionObserver(
|
|
79
|
+
(entries) => {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.isIntersecting) {
|
|
82
|
+
const uid = (entry.target as HTMLElement).dataset.outlineId;
|
|
83
|
+
if (uid) setActiveUid(uid);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{ root: container, rootMargin: "-8% 0px -72% 0px", threshold: 0 },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Tag and observe each heading node via ProseMirror pos→DOM
|
|
91
|
+
for (const h of headings) {
|
|
92
|
+
try {
|
|
93
|
+
const { node } = editor.view.domAtPos(h.pos + 1);
|
|
94
|
+
const el: HTMLElement | null =
|
|
95
|
+
node.nodeType === Node.ELEMENT_NODE
|
|
96
|
+
? (node as HTMLElement).closest("h1,h2,h3,h4,h5,h6") ?? (node as HTMLElement)
|
|
97
|
+
: (node as Node).parentElement?.closest("h1,h2,h3,h4,h5,h6") ?? null;
|
|
98
|
+
if (el) {
|
|
99
|
+
el.dataset.outlineId = h.uid;
|
|
100
|
+
observer.observe(el);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// pos might be out of range during transition — skip
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
observerRef.current = observer;
|
|
108
|
+
return () => { observer.disconnect(); };
|
|
109
|
+
}, [headings, editor, scrollContainerRef]);
|
|
110
|
+
|
|
111
|
+
const scrollToHeading = useCallback(
|
|
112
|
+
(h: Heading) => {
|
|
113
|
+
if (!editor) return;
|
|
114
|
+
try {
|
|
115
|
+
const { node } = editor.view.domAtPos(h.pos + 1);
|
|
116
|
+
const el: HTMLElement | null =
|
|
117
|
+
node.nodeType === Node.ELEMENT_NODE
|
|
118
|
+
? (node as HTMLElement).closest("h1,h2,h3,h4,h5,h6") ?? (node as HTMLElement)
|
|
119
|
+
: (node as Node).parentElement?.closest("h1,h2,h3,h4,h5,h6") ?? null;
|
|
120
|
+
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[editor],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const showToc = headings.length >= 2;
|
|
129
|
+
const [overlayOpen, setOverlayOpen] = useState(false);
|
|
130
|
+
|
|
131
|
+
const headingList = (
|
|
132
|
+
<>
|
|
133
|
+
{headings.map((h, i) => (
|
|
134
|
+
<button
|
|
135
|
+
key={`${h.uid}-${i}`}
|
|
136
|
+
onClick={() => {
|
|
137
|
+
scrollToHeading(h);
|
|
138
|
+
setOverlayOpen(false);
|
|
139
|
+
}}
|
|
140
|
+
title={h.text}
|
|
141
|
+
className={cn(
|
|
142
|
+
"text-left text-[10.5px] leading-snug py-0.5 rounded truncate transition-colors",
|
|
143
|
+
activeUid === h.uid
|
|
144
|
+
? "text-primary bg-primary/10 font-medium"
|
|
145
|
+
: "text-muted-foreground/60 hover:text-foreground hover:bg-accent",
|
|
146
|
+
)}
|
|
147
|
+
style={{ paddingLeft: `${(h.level - 1) * 8 + 4}px` }}
|
|
148
|
+
>
|
|
149
|
+
{h.text}
|
|
150
|
+
</button>
|
|
151
|
+
))}
|
|
152
|
+
</>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<>
|
|
157
|
+
{/* Reading-progress bar — thin line at very top of editor area */}
|
|
158
|
+
<div
|
|
159
|
+
aria-hidden
|
|
160
|
+
className="absolute top-0 left-0 right-0 h-0.5 z-30 pointer-events-none"
|
|
161
|
+
>
|
|
162
|
+
<div
|
|
163
|
+
className="h-full bg-primary/35 transition-[width] duration-100 ease-out"
|
|
164
|
+
style={{ width: `${scrollProgress * 100}%` }}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{/* TOC rail — xl+ screens (enough side gutter to avoid content overlap) */}
|
|
169
|
+
{showToc && (
|
|
170
|
+
<div className="absolute right-1 top-4 z-20 hidden xl:block w-40">
|
|
171
|
+
<button
|
|
172
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
173
|
+
className="flex items-center gap-1 mb-1.5 px-1 py-0.5 rounded text-muted-foreground/40 hover:text-muted-foreground hover:bg-accent transition-colors text-[10px]"
|
|
174
|
+
aria-label={collapsed ? "Expand outline" : "Collapse outline"}
|
|
175
|
+
>
|
|
176
|
+
<List className="h-3 w-3" />
|
|
177
|
+
{!collapsed && <span>Outline</span>}
|
|
178
|
+
</button>
|
|
179
|
+
{!collapsed && (
|
|
180
|
+
<nav
|
|
181
|
+
aria-label="Document outline"
|
|
182
|
+
className="flex flex-col gap-px max-h-[50vh] overflow-y-auto pr-1"
|
|
183
|
+
>
|
|
184
|
+
{headingList}
|
|
185
|
+
</nav>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{/* Floating toggle + overlay — below xl, where there's no room for a rail */}
|
|
191
|
+
{showToc && (
|
|
192
|
+
<div className="absolute right-2 top-2 z-30 xl:hidden">
|
|
193
|
+
<button
|
|
194
|
+
onClick={() => setOverlayOpen((o) => !o)}
|
|
195
|
+
className="flex items-center gap-1 px-1.5 py-1 rounded bg-background/80 backdrop-blur border border-border/60 text-muted-foreground/60 hover:text-foreground hover:bg-accent transition-colors text-[10px] shadow-sm"
|
|
196
|
+
aria-label="Document outline"
|
|
197
|
+
aria-expanded={overlayOpen}
|
|
198
|
+
>
|
|
199
|
+
<List className="h-3.5 w-3.5" />
|
|
200
|
+
</button>
|
|
201
|
+
{overlayOpen && (
|
|
202
|
+
<>
|
|
203
|
+
<button
|
|
204
|
+
aria-hidden
|
|
205
|
+
tabIndex={-1}
|
|
206
|
+
className="fixed inset-0 z-0 cursor-default"
|
|
207
|
+
onClick={() => setOverlayOpen(false)}
|
|
208
|
+
/>
|
|
209
|
+
<nav
|
|
210
|
+
aria-label="Document outline"
|
|
211
|
+
className="absolute right-0 top-9 z-10 w-52 max-h-[60vh] overflow-y-auto flex flex-col gap-px rounded-lg border border-border bg-popover p-2 shadow-lg"
|
|
212
|
+
>
|
|
213
|
+
{headingList}
|
|
214
|
+
</nav>
|
|
215
|
+
</>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|