mosage 0.1.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/LICENSE +1 -0
- package/README.md +16 -217
- 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/init-Bbtj2pxF.js +262 -0
- 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 +84 -64
- package/skills/apply-comments/SKILL.md +43 -56
- package/skills/create-doc/SKILL.md +106 -0
- package/skills/create-theme/SKILL.md +184 -0
- package/skills/current-doc/SKILL.md +120 -0
- package/skills/doc-authoring/SKILL.md +434 -0
- package/skills/doc-authoring/references/assets.md +47 -0
- package/skills/doc-authoring/references/design-system.md +81 -0
- package/skills/doc-authoring/references/long-form.md +131 -0
- package/skills/doc-authoring/references/pagination.md +118 -0
- package/skills/doc-authoring/references/tables-and-charts.md +161 -0
- 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 +27 -0
- package/template/README.md +39 -0
- package/template/docs/getting-started/index.tsx +230 -0
- package/template/mosage.config.ts +5 -0
- package/template/package.json +24 -0
- package/template/tsconfig.json +17 -0
- package/README.en.md +0 -57
- package/dist/cli.js +0 -4545
- package/dist/web/assets/index-Czg2WeHe.js +0 -182
- package/dist/web/assets/index-DKAyt92W.css +0 -1
- package/dist/web/index.html +0 -15
- package/skills/current-position/SKILL.md +0 -65
- package/skills/kickoff/SKILL.md +0 -105
- package/skills/mosage-reference/SKILL.md +0 -164
- package/skills/outline/SKILL.md +0 -79
- package/skills/review/SKILL.md +0 -64
- package/skills/write-chapter/SKILL.md +0 -58
- package/template/book/STYLE.md +0 -6
- package/template/book/assets/.gitkeep +0 -0
- package/template/book/book.yaml +0 -42
- package/template/book/brief.md +0 -6
- package/template/book/chapters/.gitkeep +0 -0
- package/template/book/notes/README.md +0 -7
- package/template/project/AGENTS.md +0 -82
- package/template/project/CLAUDE.md +0 -1
- package/template/project/README.md +0 -48
- package/template/project/books/.gitkeep +0 -0
- package/template/project/gitignore +0 -5
- package/template/project/mosage.yaml +0 -23
- package/template/project/notes/README.md +0 -8
- package/template/project/package.json +0 -14
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import type { DesignSystem } from '../lib/design';
|
|
3
|
+
import type { FlowSection } from '../lib/flow';
|
|
4
|
+
import type { ExtractedNote } from '../lib/footnotes';
|
|
5
|
+
import { PAGE_ATTR, PAGE_INDEX_ATTR } from '../lib/outline';
|
|
6
|
+
import { DocPageProvider, useDocPageCount } from '../lib/page-context';
|
|
7
|
+
import { Footnotes } from './footnote';
|
|
8
|
+
|
|
9
|
+
export const FLOW_BLOCK_ATTR = 'data-od-flow-block';
|
|
10
|
+
/** Around the section's footer, as `display: contents` so it lays out as if absent. */
|
|
11
|
+
export const FLOW_FOOTER_ATTR = 'data-od-flow-footer';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The page shell a flow section renders into. The framework owns the margin and
|
|
15
|
+
* base typography here — that is the trade for not hand-splitting pages — while
|
|
16
|
+
* the blocks keep their own styles.
|
|
17
|
+
*
|
|
18
|
+
* It is a column so the footnote area can sit at the foot of the sheet rather
|
|
19
|
+
* than immediately under the last paragraph. The blocks stay inside one block
|
|
20
|
+
* container, so their margins collapse exactly as they did when measured.
|
|
21
|
+
*/
|
|
22
|
+
export function flowShellStyle(design: DesignSystem | undefined, padding?: number): CSSProperties {
|
|
23
|
+
return {
|
|
24
|
+
width: '100%',
|
|
25
|
+
height: '100%',
|
|
26
|
+
boxSizing: 'border-box',
|
|
27
|
+
padding: padding ?? design?.margin ?? 76,
|
|
28
|
+
background: 'var(--od-bg)',
|
|
29
|
+
color: 'var(--od-text)',
|
|
30
|
+
fontFamily: 'var(--od-font-body)',
|
|
31
|
+
fontSize: 'var(--od-size-body)',
|
|
32
|
+
lineHeight: 'var(--od-leading)',
|
|
33
|
+
position: 'relative',
|
|
34
|
+
overflow: 'hidden',
|
|
35
|
+
display: 'flex',
|
|
36
|
+
flexDirection: 'column',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* With a `sheet`, the block is a page frame of its own: the outline and
|
|
42
|
+
* numbering scans, and `useDocPageNumber()`, see the sheet it printed on even
|
|
43
|
+
* when it is laid out in one continuous column.
|
|
44
|
+
*/
|
|
45
|
+
export function FlowBlock({ children, sheet }: { children?: ReactNode; sheet?: number }) {
|
|
46
|
+
const total = useDocPageCount();
|
|
47
|
+
if (sheet === undefined) return <div {...{ [FLOW_BLOCK_ATTR]: '' }}>{children}</div>;
|
|
48
|
+
return (
|
|
49
|
+
<div {...{ [FLOW_BLOCK_ATTR]: '', [PAGE_ATTR]: '', [PAGE_INDEX_ATTR]: sheet }}>
|
|
50
|
+
<DocPageProvider index={sheet} total={total}>
|
|
51
|
+
{children}
|
|
52
|
+
</DocPageProvider>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function FlowPage({
|
|
58
|
+
section,
|
|
59
|
+
design,
|
|
60
|
+
blockIndices,
|
|
61
|
+
blocks,
|
|
62
|
+
notes,
|
|
63
|
+
sheets,
|
|
64
|
+
}: {
|
|
65
|
+
section: FlowSection;
|
|
66
|
+
design: DesignSystem | undefined;
|
|
67
|
+
blockIndices: number[];
|
|
68
|
+
/** Blocks with footnotes already lifted out; falls back to the authored ones. */
|
|
69
|
+
blocks?: ReactNode[];
|
|
70
|
+
notes?: ExtractedNote[];
|
|
71
|
+
/** Block index → the sheet it printed on, for a copy laid out as one column. */
|
|
72
|
+
sheets?: ReadonlyMap<number, number>;
|
|
73
|
+
}) {
|
|
74
|
+
const Footer = section.footer;
|
|
75
|
+
const source = blocks ?? section.blocks;
|
|
76
|
+
return (
|
|
77
|
+
<div style={flowShellStyle(design, section.padding)}>
|
|
78
|
+
<div style={{ flex: 1, minHeight: 0 }}>
|
|
79
|
+
{blockIndices.map((index) => (
|
|
80
|
+
<FlowBlock key={index} sheet={sheets?.get(index)}>
|
|
81
|
+
{source[index]}
|
|
82
|
+
</FlowBlock>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
{notes && notes.length > 0 && <Footnotes notes={notes} />}
|
|
86
|
+
{Footer && (
|
|
87
|
+
<div {...{ [FLOW_FOOTER_ATTR]: '' }} style={{ display: 'contents' }}>
|
|
88
|
+
<Footer />
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|