mosage 0.2.0 → 0.8.0
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/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { DesignSystem } from '../../lib/design';
|
|
3
|
+
|
|
4
|
+
type FetchedState = {
|
|
5
|
+
design: DesignSystem | null;
|
|
6
|
+
exists: boolean;
|
|
7
|
+
warning: string | null;
|
|
8
|
+
loaded: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type UseDesignReturn = FetchedState & {
|
|
12
|
+
refresh: () => Promise<void>;
|
|
13
|
+
save: (patch: Partial<DesignSystem>) => Promise<{ ok: boolean; error?: string }>;
|
|
14
|
+
reset: () => Promise<{ ok: boolean; error?: string }>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const INITIAL: FetchedState = { design: null, exists: false, warning: null, loaded: false };
|
|
18
|
+
|
|
19
|
+
export function useDesign(docId: string): UseDesignReturn {
|
|
20
|
+
const [state, setState] = useState<FetchedState>(INITIAL);
|
|
21
|
+
const docIdRef = useRef(docId);
|
|
22
|
+
docIdRef.current = docId;
|
|
23
|
+
|
|
24
|
+
const refresh = useCallback(async () => {
|
|
25
|
+
const id = docIdRef.current;
|
|
26
|
+
if (!id) return;
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`/__design?docId=${encodeURIComponent(id)}`);
|
|
29
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
30
|
+
const body = (await res.json()) as {
|
|
31
|
+
design: DesignSystem;
|
|
32
|
+
exists: boolean;
|
|
33
|
+
warning: string | null;
|
|
34
|
+
};
|
|
35
|
+
setState({ design: body.design, exists: body.exists, warning: body.warning, loaded: true });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setState((s) => ({ ...s, warning: String((err as Error).message), loaded: true }));
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setState(INITIAL);
|
|
43
|
+
void refresh();
|
|
44
|
+
}, [refresh]);
|
|
45
|
+
|
|
46
|
+
const save = useCallback(async (patch: Partial<DesignSystem>) => {
|
|
47
|
+
const id = docIdRef.current;
|
|
48
|
+
if (!id) return { ok: false, error: 'no document id' };
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`/__design?docId=${encodeURIComponent(id)}`, {
|
|
51
|
+
method: 'PUT',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ patch }),
|
|
54
|
+
});
|
|
55
|
+
const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem };
|
|
56
|
+
if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
|
57
|
+
if (body.design) {
|
|
58
|
+
setState((s) => ({ ...s, design: body.design ?? s.design, exists: true, warning: null }));
|
|
59
|
+
}
|
|
60
|
+
return { ok: true };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, error: String((err as Error).message) };
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const reset = useCallback(async () => {
|
|
67
|
+
const id = docIdRef.current;
|
|
68
|
+
if (!id) return { ok: false, error: 'no document id' };
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`/__design/reset?docId=${encodeURIComponent(id)}`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
});
|
|
73
|
+
const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem };
|
|
74
|
+
if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
|
|
75
|
+
if (body.design) {
|
|
76
|
+
setState((s) => ({ ...s, design: body.design ?? s.design, exists: true, warning: null }));
|
|
77
|
+
}
|
|
78
|
+
return { ok: true };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, error: String((err as Error).message) };
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return { ...state, refresh, save, reset };
|
|
85
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import type { LabelKind } from '../lib/labels';
|
|
3
|
+
import { Figure } from './numbering';
|
|
4
|
+
|
|
5
|
+
export type DiagramSource = {
|
|
6
|
+
svg: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type DiagramProps = {
|
|
12
|
+
/** The compiled module from `import chart from './architecture.mmd'`. */
|
|
13
|
+
chart: DiagramSource;
|
|
14
|
+
/** Caption text. Given one, the drawing is numbered like any other figure. */
|
|
15
|
+
caption?: ReactNode;
|
|
16
|
+
captionText?: string;
|
|
17
|
+
kind?: LabelKind;
|
|
18
|
+
id?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Drawn width in CSS px. Defaults to the diagram's natural size, capped to
|
|
21
|
+
* the column — a diagram wider than the text block is a layout fault, and
|
|
22
|
+
* `mosage check` would report it as one.
|
|
23
|
+
*/
|
|
24
|
+
width?: number;
|
|
25
|
+
align?: 'left' | 'center';
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A diagram compiled from Mermaid-flavoured text at build time. The SVG is
|
|
32
|
+
* inlined rather than referenced through `<img>` so it inherits the document's
|
|
33
|
+
* theme variables — an `<img src="data:…">` would be painted in a document of
|
|
34
|
+
* its own, with none of this one's colours or faces.
|
|
35
|
+
*/
|
|
36
|
+
export function Diagram({
|
|
37
|
+
chart,
|
|
38
|
+
caption,
|
|
39
|
+
captionText,
|
|
40
|
+
kind = 'figure',
|
|
41
|
+
id,
|
|
42
|
+
width,
|
|
43
|
+
align = 'center',
|
|
44
|
+
style,
|
|
45
|
+
className,
|
|
46
|
+
}: DiagramProps) {
|
|
47
|
+
const drawing = (
|
|
48
|
+
<div
|
|
49
|
+
className={caption ? undefined : className}
|
|
50
|
+
style={{
|
|
51
|
+
width: width ?? chart.width,
|
|
52
|
+
maxWidth: '100%',
|
|
53
|
+
marginLeft: align === 'center' ? 'auto' : undefined,
|
|
54
|
+
marginRight: align === 'center' ? 'auto' : undefined,
|
|
55
|
+
...(caption ? undefined : style),
|
|
56
|
+
}}
|
|
57
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: the SVG is produced by this package's own renderer at build time, never from document input at runtime
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: chart.svg }}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!caption) return drawing;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Figure
|
|
66
|
+
{...(id ? { id } : {})}
|
|
67
|
+
caption={caption}
|
|
68
|
+
{...(captionText ? { captionText } : {})}
|
|
69
|
+
kind={kind}
|
|
70
|
+
className={className}
|
|
71
|
+
{...(style ? { style } : {})}
|
|
72
|
+
>
|
|
73
|
+
{drawing}
|
|
74
|
+
</Figure>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Check, Copy, FileIcon, Loader2 } from 'lucide-react';
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type Asset,
|
|
5
|
+
formatBytes,
|
|
6
|
+
GLOBAL_SCOPE,
|
|
7
|
+
importSnippet,
|
|
8
|
+
isPreviewable,
|
|
9
|
+
listAssets,
|
|
10
|
+
} from '../lib/assets';
|
|
11
|
+
import { cn } from '../lib/utils';
|
|
12
|
+
|
|
13
|
+
type Group = { scope: string; label: string; assets: Asset[] };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The images this document can reach, without leaving it: its own `assets/`
|
|
17
|
+
* folder plus the shared project folder. Clicking one gives the import line to
|
|
18
|
+
* paste into the source.
|
|
19
|
+
*/
|
|
20
|
+
export function DocAssets({ docId }: { docId: string }) {
|
|
21
|
+
const [groups, setGroups] = useState<Group[] | null>(null);
|
|
22
|
+
const [selected, setSelected] = useState<Asset | null>(null);
|
|
23
|
+
const [copied, setCopied] = useState(false);
|
|
24
|
+
|
|
25
|
+
const load = useCallback(async () => {
|
|
26
|
+
const [own, global] = await Promise.all([listAssets(docId), listAssets(GLOBAL_SCOPE)]);
|
|
27
|
+
setGroups([
|
|
28
|
+
{ scope: docId, label: 'This document', assets: own.ok ? own.value : [] },
|
|
29
|
+
{ scope: GLOBAL_SCOPE, label: 'Project', assets: global.ok ? global.value : [] },
|
|
30
|
+
]);
|
|
31
|
+
}, [docId]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setGroups(null);
|
|
35
|
+
void load();
|
|
36
|
+
if (!import.meta.hot) return;
|
|
37
|
+
const handler = () => void load();
|
|
38
|
+
import.meta.hot.on('mosage:files-changed', handler);
|
|
39
|
+
return () => {
|
|
40
|
+
import.meta.hot?.off('mosage:files-changed', handler);
|
|
41
|
+
};
|
|
42
|
+
}, [load]);
|
|
43
|
+
|
|
44
|
+
const copyImport = async (asset: Asset) => {
|
|
45
|
+
try {
|
|
46
|
+
await navigator.clipboard.writeText(importSnippet(asset));
|
|
47
|
+
setCopied(true);
|
|
48
|
+
setTimeout(() => setCopied(false), 1200);
|
|
49
|
+
} catch {}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (groups === null) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="grid flex-1 place-items-center">
|
|
55
|
+
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const empty = groups.every((group) => group.assets.length === 0);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex-1 overflow-y-auto px-3 pb-6">
|
|
64
|
+
{empty && (
|
|
65
|
+
<p className="px-1 py-2 text-muted-foreground text-xs leading-relaxed">
|
|
66
|
+
No assets yet. Drop files into <code className="font-mono">docs/{docId}/assets/</code> or
|
|
67
|
+
upload them from the Assets page.
|
|
68
|
+
</p>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{groups.map((group) =>
|
|
72
|
+
group.assets.length === 0 ? null : (
|
|
73
|
+
<section key={group.scope} className="mb-4">
|
|
74
|
+
<h3 className="mb-1.5 px-1 text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
75
|
+
{group.label}
|
|
76
|
+
</h3>
|
|
77
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
78
|
+
{group.assets.map((asset) => (
|
|
79
|
+
<button
|
|
80
|
+
key={`${group.scope}/${asset.name}`}
|
|
81
|
+
type="button"
|
|
82
|
+
title={asset.name}
|
|
83
|
+
onClick={() => setSelected(asset)}
|
|
84
|
+
className={cn(
|
|
85
|
+
'grid h-16 place-items-center overflow-hidden rounded border bg-muted p-1 transition-colors',
|
|
86
|
+
selected?.url === asset.url
|
|
87
|
+
? 'border-foreground'
|
|
88
|
+
: 'border-border hover:border-foreground/40',
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{isPreviewable(asset.mime) ? (
|
|
92
|
+
<img
|
|
93
|
+
src={asset.url}
|
|
94
|
+
alt={asset.name}
|
|
95
|
+
className="max-h-full max-w-full object-contain"
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<FileIcon className="size-4 text-muted-foreground" />
|
|
99
|
+
)}
|
|
100
|
+
</button>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
),
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{selected && (
|
|
108
|
+
<div className="sticky bottom-0 rounded-md border border-border bg-background p-2">
|
|
109
|
+
<p className="truncate font-medium text-[11px]">{selected.name}</p>
|
|
110
|
+
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
|
111
|
+
{formatBytes(selected.size)}
|
|
112
|
+
{selected.unused ? ' · unused' : ''}
|
|
113
|
+
</p>
|
|
114
|
+
<code className="mt-1.5 block truncate rounded bg-muted px-1.5 py-1 font-mono text-[10px]">
|
|
115
|
+
{selected.importPath}
|
|
116
|
+
</code>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => copyImport(selected)}
|
|
120
|
+
className="mt-1.5 flex w-full items-center justify-center gap-1 rounded border border-border px-2 py-1 text-[11px] transition-colors hover:bg-accent"
|
|
121
|
+
>
|
|
122
|
+
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
123
|
+
{copied ? 'Copied' : 'Copy import'}
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find text in the document.
|
|
3
|
+
*
|
|
4
|
+
* Matches are drawn with the CSS Custom Highlight API, which paints ranges held
|
|
5
|
+
* beside the DOM instead of wrapping anything in it. Wrapping would mean editing
|
|
6
|
+
* a tree React owns: the next render throws the marks away, or keeps them and
|
|
7
|
+
* loses the reader's place. Neither is recoverable from inside a highlighter.
|
|
8
|
+
*
|
|
9
|
+
* A browser without the API still gets working navigation — the view scrolls to
|
|
10
|
+
* each hit — it just cannot tint it. That is worth having; refusing to search at
|
|
11
|
+
* all because it cannot be coloured is not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
const ALL = 'od-search';
|
|
18
|
+
const ACTIVE = 'od-search-active';
|
|
19
|
+
|
|
20
|
+
type Hit = { range: Range; page: number };
|
|
21
|
+
|
|
22
|
+
/* Case-insensitive, and accent-insensitive where the browser can be: a reader
|
|
23
|
+
searching for "resume" means the word, not the spelling with accents. Defined
|
|
24
|
+
outside the component so it is one function, not a new one every render —
|
|
25
|
+
which is also what stops it having to appear in a dependency list. */
|
|
26
|
+
const fold = (value: string): string => value.normalize('NFKD').toLowerCase();
|
|
27
|
+
|
|
28
|
+
const highlightsSupported = (): boolean =>
|
|
29
|
+
typeof CSS !== 'undefined' && 'highlights' in CSS && typeof Highlight !== 'undefined';
|
|
30
|
+
|
|
31
|
+
function clearHighlights(): void {
|
|
32
|
+
if (!highlightsSupported()) return;
|
|
33
|
+
CSS.highlights.delete(ALL);
|
|
34
|
+
CSS.highlights.delete(ACTIVE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every text node under `root`, in document order.
|
|
39
|
+
*
|
|
40
|
+
* Script and style hold text that is not the document's text; matching inside
|
|
41
|
+
* them would send the reader to a hit they cannot see.
|
|
42
|
+
*/
|
|
43
|
+
function textNodes(root: HTMLElement): Text[] {
|
|
44
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
45
|
+
acceptNode(node) {
|
|
46
|
+
const parent = node.parentElement;
|
|
47
|
+
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
48
|
+
if (parent.closest('script,style,[data-od-search-skip]')) return NodeFilter.FILTER_REJECT;
|
|
49
|
+
return node.nodeValue?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const out: Text[] = [];
|
|
53
|
+
let node = walker.nextNode();
|
|
54
|
+
while (node) {
|
|
55
|
+
out.push(node as Text);
|
|
56
|
+
node = walker.nextNode();
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pageOf(node: Node, frames: Element[]): number {
|
|
62
|
+
const element = node.parentElement;
|
|
63
|
+
if (!element) return 0;
|
|
64
|
+
for (let i = 0; i < frames.length; i++) {
|
|
65
|
+
if (frames[i]?.contains(element)) return i + 1;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function DocSearch({
|
|
71
|
+
scrollRef,
|
|
72
|
+
pagesRef,
|
|
73
|
+
onFoundPage,
|
|
74
|
+
}: {
|
|
75
|
+
scrollRef: React.RefObject<HTMLElement | null>;
|
|
76
|
+
pagesRef: React.RefObject<HTMLElement | null>;
|
|
77
|
+
onFoundPage?: (page: number) => void;
|
|
78
|
+
}) {
|
|
79
|
+
const [open, setOpen] = useState(false);
|
|
80
|
+
const [query, setQuery] = useState('');
|
|
81
|
+
const [hits, setHits] = useState<Hit[]>([]);
|
|
82
|
+
const [at, setAt] = useState(0);
|
|
83
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
84
|
+
|
|
85
|
+
const supported = useMemo(highlightsSupported, []);
|
|
86
|
+
|
|
87
|
+
const search = useCallback(
|
|
88
|
+
(text: string) => {
|
|
89
|
+
const root = pagesRef.current;
|
|
90
|
+
if (!root || text.trim() === '') {
|
|
91
|
+
clearHighlights();
|
|
92
|
+
setHits([]);
|
|
93
|
+
setAt(0);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const needle = fold(text);
|
|
98
|
+
const frames = Array.from(root.children);
|
|
99
|
+
const found: Hit[] = [];
|
|
100
|
+
|
|
101
|
+
for (const node of textNodes(root)) {
|
|
102
|
+
const haystack = fold(node.nodeValue ?? '');
|
|
103
|
+
let from = haystack.indexOf(needle);
|
|
104
|
+
while (from !== -1) {
|
|
105
|
+
const range = document.createRange();
|
|
106
|
+
try {
|
|
107
|
+
range.setStart(node, from);
|
|
108
|
+
range.setEnd(node, from + needle.length);
|
|
109
|
+
found.push({ range, page: pageOf(node, frames) });
|
|
110
|
+
} catch {
|
|
111
|
+
/* Folding can change length, which puts the offset past the node.
|
|
112
|
+
Skipping that hit is better than throwing away the whole search. */
|
|
113
|
+
}
|
|
114
|
+
from = haystack.indexOf(needle, from + needle.length);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setHits(found);
|
|
119
|
+
setAt(found.length > 0 ? 1 : 0);
|
|
120
|
+
},
|
|
121
|
+
[pagesRef],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/* Painting is a side effect of the hits and the cursor, not of typing —
|
|
125
|
+
otherwise the active hit stays tinted after the reader moves off it. */
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!supported) return;
|
|
128
|
+
if (hits.length === 0) {
|
|
129
|
+
clearHighlights();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const active = hits[at - 1];
|
|
133
|
+
CSS.highlights.set(ALL, new Highlight(...hits.map((hit) => hit.range)));
|
|
134
|
+
if (active) CSS.highlights.set(ACTIVE, new Highlight(active.range));
|
|
135
|
+
return clearHighlights;
|
|
136
|
+
}, [hits, at, supported]);
|
|
137
|
+
|
|
138
|
+
const go = useCallback(
|
|
139
|
+
(step: number) => {
|
|
140
|
+
if (hits.length === 0) return;
|
|
141
|
+
const next = ((at - 1 + step + hits.length) % hits.length) + 1;
|
|
142
|
+
setAt(next);
|
|
143
|
+
const hit = hits[next - 1];
|
|
144
|
+
if (!hit) return;
|
|
145
|
+
const target = hit.range.startContainer.parentElement;
|
|
146
|
+
const root = scrollRef.current;
|
|
147
|
+
if (target && root) {
|
|
148
|
+
const offset = target.getBoundingClientRect().top - root.getBoundingClientRect().top;
|
|
149
|
+
root.scrollTo({ top: root.scrollTop + offset - 120, behavior: 'smooth' });
|
|
150
|
+
}
|
|
151
|
+
if (hit.page > 0) onFoundPage?.(hit.page);
|
|
152
|
+
},
|
|
153
|
+
[at, hits, scrollRef, onFoundPage],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const close = useCallback(() => {
|
|
157
|
+
setOpen(false);
|
|
158
|
+
setQuery('');
|
|
159
|
+
clearHighlights();
|
|
160
|
+
setHits([]);
|
|
161
|
+
setAt(0);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
/* No ⌘F: that is the browser's own find, and taking it would replace a
|
|
165
|
+
control the reader already trusts with one that only searches this pane. */
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!open) return;
|
|
168
|
+
const onKey = (event: KeyboardEvent) => {
|
|
169
|
+
if (event.key === 'Escape') close();
|
|
170
|
+
};
|
|
171
|
+
window.addEventListener('keydown', onKey);
|
|
172
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
173
|
+
}, [open, close]);
|
|
174
|
+
|
|
175
|
+
useEffect(() => () => clearHighlights(), []);
|
|
176
|
+
|
|
177
|
+
if (!open) {
|
|
178
|
+
return (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
aria-label="Find in document"
|
|
182
|
+
title="Find in document"
|
|
183
|
+
onClick={() => {
|
|
184
|
+
setOpen(true);
|
|
185
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
186
|
+
}}
|
|
187
|
+
className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
188
|
+
>
|
|
189
|
+
<Search className="size-3.5" />
|
|
190
|
+
</button>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="flex items-center gap-1 rounded-md border border-border px-1.5 py-0.5 focus-within:border-foreground/40">
|
|
196
|
+
<Search className="size-3.5 flex-none text-muted-foreground" />
|
|
197
|
+
<input
|
|
198
|
+
ref={inputRef}
|
|
199
|
+
value={query}
|
|
200
|
+
onChange={(event) => {
|
|
201
|
+
setQuery(event.target.value);
|
|
202
|
+
search(event.target.value);
|
|
203
|
+
}}
|
|
204
|
+
onKeyDown={(event) => {
|
|
205
|
+
if (event.key === 'Enter') {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
go(event.shiftKey ? -1 : 1);
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
210
|
+
placeholder="Find"
|
|
211
|
+
aria-label="Find in document"
|
|
212
|
+
className="w-28 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
|
213
|
+
/>
|
|
214
|
+
<span className="flex-none font-mono text-[11px] text-muted-foreground tabular-nums">
|
|
215
|
+
{query === '' ? '' : hits.length === 0 ? '0' : `${at}/${hits.length}`}
|
|
216
|
+
</span>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
aria-label="Previous match"
|
|
220
|
+
title="Previous match (⇧⏎)"
|
|
221
|
+
onClick={() => go(-1)}
|
|
222
|
+
disabled={hits.length === 0}
|
|
223
|
+
className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
|
224
|
+
>
|
|
225
|
+
<ChevronUp className="size-3.5" />
|
|
226
|
+
</button>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
aria-label="Next match"
|
|
230
|
+
title="Next match (⏎)"
|
|
231
|
+
onClick={() => go(1)}
|
|
232
|
+
disabled={hits.length === 0}
|
|
233
|
+
className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
|
234
|
+
>
|
|
235
|
+
<ChevronDown className="size-3.5" />
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label="Close search"
|
|
240
|
+
title="Close search (Esc)"
|
|
241
|
+
onClick={close}
|
|
242
|
+
className="flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
243
|
+
>
|
|
244
|
+
<X className="size-3.5" />
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { DesignSystem } from '../lib/design';
|
|
3
|
+
import type { OutlineEntry } from '../lib/outline';
|
|
4
|
+
import type { PageGeometry } from '../lib/sdk';
|
|
5
|
+
import type { ExpandedPage } from '../lib/use-doc-pages';
|
|
6
|
+
import { cn } from '../lib/utils';
|
|
7
|
+
import { DocAssets } from './doc-assets';
|
|
8
|
+
import { PageFrame } from './page-frame';
|
|
9
|
+
|
|
10
|
+
const THUMB_WIDTH = 116;
|
|
11
|
+
|
|
12
|
+
type Tab = 'pages' | 'outline' | 'assets';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
docId: string;
|
|
16
|
+
pages: ExpandedPage[];
|
|
17
|
+
geometry: PageGeometry;
|
|
18
|
+
design?: DesignSystem;
|
|
19
|
+
currentPage: number;
|
|
20
|
+
entries: OutlineEntry[];
|
|
21
|
+
activeId: string | null;
|
|
22
|
+
onSelectPage: (page: number) => void;
|
|
23
|
+
onSelectEntry: (entry: OutlineEntry) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function DocSidebar({
|
|
27
|
+
docId,
|
|
28
|
+
pages,
|
|
29
|
+
geometry,
|
|
30
|
+
design,
|
|
31
|
+
currentPage,
|
|
32
|
+
entries,
|
|
33
|
+
activeId,
|
|
34
|
+
onSelectPage,
|
|
35
|
+
onSelectEntry,
|
|
36
|
+
}: Props) {
|
|
37
|
+
const [tab, setTab] = useState<Tab>('pages');
|
|
38
|
+
const railRef = useRef<HTMLDivElement>(null);
|
|
39
|
+
// The assets panel talks to the dev API, so it has nothing to show in a
|
|
40
|
+
// static build.
|
|
41
|
+
const tabs = useMemo<Tab[]>(
|
|
42
|
+
() => (import.meta.env.DEV ? ['pages', 'outline', 'assets'] : ['pages', 'outline']),
|
|
43
|
+
[],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Follow the reader: keep the active thumbnail in view as the main pane scrolls.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (tab !== 'pages') return;
|
|
49
|
+
const rail = railRef.current;
|
|
50
|
+
const active = rail?.querySelector<HTMLElement>(`[data-thumb-page="${currentPage}"]`);
|
|
51
|
+
if (!rail || !active) return;
|
|
52
|
+
const top = active.offsetTop;
|
|
53
|
+
const bottom = top + active.offsetHeight;
|
|
54
|
+
if (top < rail.scrollTop || bottom > rail.scrollTop + rail.clientHeight) {
|
|
55
|
+
rail.scrollTo({ top: top - rail.clientHeight / 3, behavior: 'smooth' });
|
|
56
|
+
}
|
|
57
|
+
}, [currentPage, tab]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<aside className="hidden w-56 flex-none flex-col border-border border-r bg-background md:flex">
|
|
61
|
+
<div className="flex flex-none gap-1 p-2">
|
|
62
|
+
{tabs.map((value) => (
|
|
63
|
+
<button
|
|
64
|
+
key={value}
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={() => setTab(value)}
|
|
67
|
+
className={cn(
|
|
68
|
+
'flex-1 rounded-md px-2 py-1 text-[11px] capitalize transition-colors',
|
|
69
|
+
tab === value
|
|
70
|
+
? 'bg-accent font-medium text-foreground'
|
|
71
|
+
: 'text-muted-foreground hover:bg-accent/60',
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{value}
|
|
75
|
+
</button>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{tab === 'assets' ? (
|
|
80
|
+
<DocAssets docId={docId} />
|
|
81
|
+
) : tab === 'pages' ? (
|
|
82
|
+
<div ref={railRef} className="flex-1 overflow-y-auto px-3 pb-6">
|
|
83
|
+
<div className="flex flex-col items-center gap-3">
|
|
84
|
+
{pages.map((entry, index) => {
|
|
85
|
+
const page = index + 1;
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
key={entry.key}
|
|
89
|
+
type="button"
|
|
90
|
+
data-thumb-page={page}
|
|
91
|
+
onClick={() => onSelectPage(page)}
|
|
92
|
+
className="flex flex-col items-center gap-1"
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className={cn(
|
|
96
|
+
'overflow-hidden rounded-sm ring-1 transition-shadow',
|
|
97
|
+
page === currentPage
|
|
98
|
+
? 'ring-2 ring-foreground'
|
|
99
|
+
: 'ring-border hover:ring-foreground/40',
|
|
100
|
+
)}
|
|
101
|
+
style={{
|
|
102
|
+
width: THUMB_WIDTH,
|
|
103
|
+
height: geometry.height * (THUMB_WIDTH / geometry.width),
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<PageFrame
|
|
107
|
+
index={index}
|
|
108
|
+
total={pages.length}
|
|
109
|
+
geometry={geometry}
|
|
110
|
+
scale={THUMB_WIDTH / geometry.width}
|
|
111
|
+
design={design}
|
|
112
|
+
flat
|
|
113
|
+
>
|
|
114
|
+
{entry.content}
|
|
115
|
+
</PageFrame>
|
|
116
|
+
</div>
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
'font-mono text-[10px] tabular-nums',
|
|
120
|
+
page === currentPage ? 'text-foreground' : 'text-muted-foreground',
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{page}
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<nav className="flex-1 overflow-y-auto px-2 pb-6">
|
|
132
|
+
{entries.length === 0 ? (
|
|
133
|
+
<p className="px-2 py-1 text-muted-foreground text-xs leading-relaxed">
|
|
134
|
+
No headings yet. Add an <code className="font-mono">h1</code>/
|
|
135
|
+
<code className="font-mono">h2</code> to a page and it shows up here.
|
|
136
|
+
</p>
|
|
137
|
+
) : (
|
|
138
|
+
entries.map((entry) => (
|
|
139
|
+
<button
|
|
140
|
+
key={entry.id}
|
|
141
|
+
type="button"
|
|
142
|
+
onClick={() => onSelectEntry(entry)}
|
|
143
|
+
className={cn(
|
|
144
|
+
'flex w-full items-baseline gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors hover:bg-accent',
|
|
145
|
+
entry.id === activeId
|
|
146
|
+
? 'bg-accent font-medium text-foreground'
|
|
147
|
+
: 'text-muted-foreground',
|
|
148
|
+
)}
|
|
149
|
+
style={{ paddingLeft: 8 + (entry.level - 1) * 12 }}
|
|
150
|
+
>
|
|
151
|
+
<span className="line-clamp-2 flex-1">{entry.text}</span>
|
|
152
|
+
<span className="font-mono text-[10px] text-muted-foreground tabular-nums">
|
|
153
|
+
{entry.page}
|
|
154
|
+
</span>
|
|
155
|
+
</button>
|
|
156
|
+
))
|
|
157
|
+
)}
|
|
158
|
+
</nav>
|
|
159
|
+
)}
|
|
160
|
+
</aside>
|
|
161
|
+
);
|
|
162
|
+
}
|