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.
Files changed (121) hide show
  1. package/README.md +16 -15
  2. package/bin.js +2 -0
  3. package/dist/build-C7NW_3Pk.js +14 -0
  4. package/dist/check-CP4873Wx.js +41 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +228 -0
  7. package/dist/config-DPm1BBAb.js +2619 -0
  8. package/dist/config-TlTe7Ona.d.ts +24 -0
  9. package/dist/context-BqsdSrAQ.js +1084 -0
  10. package/dist/dev-Biz42qlu.js +17 -0
  11. package/dist/diagram-xlVDekYk.js +763 -0
  12. package/dist/export-Bi6nuxjT.js +31 -0
  13. package/dist/import-D2jNB07F.js +25 -0
  14. package/dist/index.d.ts +455 -0
  15. package/dist/index.js +693 -0
  16. package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
  17. package/dist/preview-CLm51aRt.js +19 -0
  18. package/dist/sdk-DjpX6mCv.js +51 -0
  19. package/dist/vite/index.d.ts +25 -0
  20. package/dist/vite/index.js +2 -0
  21. package/env.d.ts +83 -0
  22. package/package.json +59 -12
  23. package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
  24. package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
  25. package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
  26. package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
  27. package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
  28. package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
  29. package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
  30. package/src/app/app.tsx +42 -0
  31. package/src/app/components/data-table.tsx +196 -0
  32. package/src/app/components/design-panel/design-panel.tsx +318 -0
  33. package/src/app/components/design-panel/design-provider.tsx +121 -0
  34. package/src/app/components/design-panel/use-design.ts +85 -0
  35. package/src/app/components/diagram.tsx +76 -0
  36. package/src/app/components/doc-assets.tsx +129 -0
  37. package/src/app/components/doc-search.tsx +248 -0
  38. package/src/app/components/doc-sidebar.tsx +162 -0
  39. package/src/app/components/flow-page.tsx +93 -0
  40. package/src/app/components/footnote.tsx +204 -0
  41. package/src/app/components/image-placeholder.tsx +50 -0
  42. package/src/app/components/inspector/inspector.tsx +518 -0
  43. package/src/app/components/numbering.tsx +224 -0
  44. package/src/app/components/page-frame.tsx +70 -0
  45. package/src/app/components/sidebar/folder-item.tsx +212 -0
  46. package/src/app/components/sidebar/icon-picker.tsx +99 -0
  47. package/src/app/components/sidebar/sidebar.tsx +252 -0
  48. package/src/app/components/table-of-contents.tsx +93 -0
  49. package/src/app/components/theme-toggle.tsx +50 -0
  50. package/src/app/components/themes/markdown.tsx +249 -0
  51. package/src/app/components/themes/theme-preview.tsx +74 -0
  52. package/src/app/components/ui/menu.tsx +143 -0
  53. package/src/app/index.html +12 -0
  54. package/src/app/lib/agent-bridge.ts +140 -0
  55. package/src/app/lib/assets.ts +151 -0
  56. package/src/app/lib/design-presets.ts +109 -0
  57. package/src/app/lib/design.ts +88 -0
  58. package/src/app/lib/diagnostics.ts +282 -0
  59. package/src/app/lib/doc-preview.tsx +29 -0
  60. package/src/app/lib/docs.ts +26 -0
  61. package/src/app/lib/docx/extract.ts +1623 -0
  62. package/src/app/lib/docx/fonts.test.ts +136 -0
  63. package/src/app/lib/docx/fonts.ts +166 -0
  64. package/src/app/lib/docx/media.ts +102 -0
  65. package/src/app/lib/docx/model.ts +206 -0
  66. package/src/app/lib/docx/paragraph.test.ts +92 -0
  67. package/src/app/lib/docx/paragraph.ts +107 -0
  68. package/src/app/lib/docx/props.ts +187 -0
  69. package/src/app/lib/docx/styles.ts +306 -0
  70. package/src/app/lib/docx/units.ts +35 -0
  71. package/src/app/lib/docx/write.test.ts +507 -0
  72. package/src/app/lib/docx/write.ts +581 -0
  73. package/src/app/lib/docx/xml.ts +39 -0
  74. package/src/app/lib/export-docx.ts +289 -0
  75. package/src/app/lib/export-dom.ts +318 -0
  76. package/src/app/lib/export-html.ts +156 -0
  77. package/src/app/lib/export-image.ts +70 -0
  78. package/src/app/lib/export-pdf.ts +165 -0
  79. package/src/app/lib/flow-measure.test.ts +31 -0
  80. package/src/app/lib/flow-measure.ts +183 -0
  81. package/src/app/lib/flow.test.ts +110 -0
  82. package/src/app/lib/flow.ts +136 -0
  83. package/src/app/lib/folders.ts +192 -0
  84. package/src/app/lib/footnotes.test.tsx +102 -0
  85. package/src/app/lib/footnotes.ts +94 -0
  86. package/src/app/lib/inspector/fiber.ts +99 -0
  87. package/src/app/lib/labels.test.ts +18 -0
  88. package/src/app/lib/labels.ts +181 -0
  89. package/src/app/lib/outline.ts +118 -0
  90. package/src/app/lib/page-context.tsx +43 -0
  91. package/src/app/lib/page-range.test.ts +95 -0
  92. package/src/app/lib/page-range.ts +90 -0
  93. package/src/app/lib/print-ready.ts +69 -0
  94. package/src/app/lib/rasterize.ts +173 -0
  95. package/src/app/lib/scan.ts +26 -0
  96. package/src/app/lib/sdk.test.ts +32 -0
  97. package/src/app/lib/sdk.ts +115 -0
  98. package/src/app/lib/themes.ts +31 -0
  99. package/src/app/lib/use-doc-module.ts +53 -0
  100. package/src/app/lib/use-doc-pages.ts +147 -0
  101. package/src/app/lib/utils.ts +6 -0
  102. package/src/app/lib/view-mode.test.ts +91 -0
  103. package/src/app/lib/view-mode.ts +104 -0
  104. package/src/app/main.tsx +14 -0
  105. package/src/app/routes/assets.tsx +257 -0
  106. package/src/app/routes/doc.tsx +877 -0
  107. package/src/app/routes/home-shell.tsx +203 -0
  108. package/src/app/routes/home.tsx +269 -0
  109. package/src/app/routes/themes.tsx +121 -0
  110. package/src/app/styles.css +97 -0
  111. package/src/app/virtual.d.ts +30 -0
  112. package/template/AGENTS.md +1 -1
  113. package/template/README.md +24 -52
  114. package/template/docs/getting-started/index.tsx +2 -2
  115. package/template/mosage.config.ts +1 -1
  116. package/template/package.json +1 -1
  117. package/template/tsconfig.json +1 -1
  118. package/README.zh-TW.md +0 -28
  119. /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
  120. /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
  121. /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
@@ -0,0 +1,877 @@
1
+ import appConfig from 'virtual:mosage/config';
2
+ import {
3
+ ArrowLeft,
4
+ BookOpen,
5
+ Check,
6
+ Download,
7
+ FileCode2,
8
+ FileImage,
9
+ FilePen,
10
+ FileText,
11
+ GalleryVertical,
12
+ Image,
13
+ LayoutGrid,
14
+ Loader2,
15
+ type LucideIcon,
16
+ Maximize,
17
+ Minimize,
18
+ Minus,
19
+ MousePointerClick,
20
+ MoveHorizontal,
21
+ MoveVertical,
22
+ Palette,
23
+ Percent,
24
+ Plus,
25
+ } from 'lucide-react';
26
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
27
+ import { Link, useParams } from 'react-router-dom';
28
+ import { DesignPanel } from '../components/design-panel/design-panel';
29
+ import { DesignProvider } from '../components/design-panel/design-provider';
30
+ import { DocSearch } from '../components/doc-search';
31
+ import { DocSidebar } from '../components/doc-sidebar';
32
+ import { Inspector } from '../components/inspector/inspector';
33
+ import { PageFrame } from '../components/page-frame';
34
+ import { ThemeToggle } from '../components/theme-toggle';
35
+ import { Menu, MenuItem } from '../components/ui/menu';
36
+ import { useAgentBridge } from '../lib/agent-bridge';
37
+ import { exportDocAsHtml } from '../lib/export-html';
38
+ import { exportDocAsImages } from '../lib/export-image';
39
+ import { exportDocAsPdf } from '../lib/export-pdf';
40
+ import { type OutlineEntry, useDocOutline } from '../lib/outline';
41
+ import { describeSelection, type PageSelection, resolveSelection } from '../lib/page-range';
42
+ import { nextFrame, waitForFonts } from '../lib/print-ready';
43
+ import { scanDocument } from '../lib/scan';
44
+ import { resolvePageGeometry } from '../lib/sdk';
45
+ import { useDocModule } from '../lib/use-doc-module';
46
+ import { useDocPages } from '../lib/use-doc-pages';
47
+ import { cn } from '../lib/utils';
48
+ import {
49
+ gridColumns,
50
+ pageInView,
51
+ READING_LINE,
52
+ useViewMode,
53
+ VIEW_MODES,
54
+ type ViewMode,
55
+ } from '../lib/view-mode';
56
+
57
+ type DownloadFormat = 'pdf' | 'html' | 'docx' | 'png' | 'svg';
58
+
59
+ const DOWNLOAD_FORMATS = [
60
+ { format: 'pdf' as const, label: 'PDF', hint: 'True page size, print-ready', icon: FileText },
61
+ { format: 'html' as const, label: 'HTML', hint: 'Self-contained, printable', icon: FileCode2 },
62
+ { format: 'docx' as const, label: 'DOCX', hint: 'Editable in Word, reflows', icon: FilePen },
63
+ { format: 'png' as const, label: 'PNG', hint: 'Pixels, 2x — for slides and chat', icon: Image },
64
+ { format: 'svg' as const, label: 'SVG', hint: 'Vector, keeps text as text', icon: FileImage },
65
+ ];
66
+
67
+ const GUTTER = 48;
68
+ /** Breathing room left above a heading the outline jumped to. */
69
+ const HEADING_TOP_INSET = 28;
70
+ const MIN_SCALE = 0.25;
71
+ const MAX_SCALE = 2;
72
+ const PAGE_GAP = 24;
73
+ /** Facing pages meet at the spine; a hairline keeps two white sheets from reading as one. */
74
+ const SPREAD_GAP = 4;
75
+ /** A grid opens as a contact sheet: small enough to take in a chapter, large enough to tell a figure from a table. */
76
+ const GRID_SCALE = 0.3;
77
+
78
+ const VIEW_MODE_OPTIONS: Record<ViewMode, { label: string; icon: LucideIcon }> = {
79
+ continuous: { label: 'Continuous', icon: GalleryVertical },
80
+ 'two-up': { label: 'Two-up', icon: BookOpen },
81
+ grid: { label: 'Grid', icon: LayoutGrid },
82
+ };
83
+
84
+ const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End', ' ']);
85
+
86
+ const TOOL_GROUP_CLASS = 'items-center gap-0.5 rounded-md border border-border px-1 py-0.5';
87
+
88
+ const BACK_CLASS =
89
+ 'flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground';
90
+
91
+ /**
92
+ * `config.home` leaves the router on purpose. A `<Link>` resolves inside the
93
+ * app's basename, which is exactly wrong when the viewer is mounted under a
94
+ * larger site and "back" means that site.
95
+ *
96
+ * With no `home` and no document browser there is nowhere to go: `/` renders
97
+ * "not found". A control that leads nowhere is worse than no control, and a
98
+ * host that mounts the viewer this way is providing its own way back.
99
+ */
100
+ const HeaderBackLink = () => {
101
+ if (appConfig.home !== undefined) {
102
+ return (
103
+ <a href={appConfig.home} className={BACK_CLASS} aria-label="Back to workspace">
104
+ <ArrowLeft className="size-4" />
105
+ </a>
106
+ );
107
+ }
108
+ if (!appConfig.build.showDocBrowser) return null;
109
+ return (
110
+ <Link to="/" className={BACK_CLASS} aria-label="Back to documents">
111
+ <ArrowLeft className="size-4" />
112
+ </Link>
113
+ );
114
+ };
115
+
116
+ export function Doc() {
117
+ const { docId } = useParams<{ docId: string }>();
118
+ const state = useDocModule(docId);
119
+ const doc = state.doc;
120
+
121
+ const rootRef = useRef<HTMLDivElement>(null);
122
+ const scrollRef = useRef<HTMLDivElement>(null);
123
+ const pagesRef = useRef<HTMLDivElement>(null);
124
+ const [available, setAvailable] = useState({ width: 0, height: 0 });
125
+ const [zoomMode, setZoomMode] = useState<'auto' | 'fit-width' | 'fit-page'>('auto');
126
+ const [manualScale, setManualScale] = useState<number | null>(null);
127
+ const [currentPage, setCurrentPage] = useState(1);
128
+ const [download, setDownload] = useState<{ format: DownloadFormat; percent: number } | null>(
129
+ null,
130
+ );
131
+ const [downloaded, setDownloaded] = useState<DownloadFormat | null>(null);
132
+ const [selection, setSelection] = useState<PageSelection>({ kind: 'all' });
133
+ const [customRange, setCustomRange] = useState('');
134
+ const [designOpen, setDesignOpen] = useState(false);
135
+ const [inspecting, setInspecting] = useState(false);
136
+ const [isFullscreen, setIsFullscreen] = useState(false);
137
+ const [viewMode, setViewMode] = useViewMode(docId);
138
+ const chosenPage = useRef<number | null>(null);
139
+ const place = useRef<{ page: number; fraction: number; centre: number } | null>(null);
140
+ const refreshPage = useRef<() => void>(() => {});
141
+
142
+ const geometry = useMemo(() => resolvePageGeometry(doc?.meta), [doc?.meta]);
143
+ const { pages, measuring, overflowing } = useDocPages(doc, geometry);
144
+ const outline = useDocOutline();
145
+
146
+ useAgentBridge({ docId: docId ?? '', doc, pages, geometry, measuring, oversized: overflowing });
147
+
148
+ const clamp = (value: number) => Math.max(MIN_SCALE, Math.min(MAX_SCALE, value));
149
+ // Two-up fits the spread rather than one sheet — the pair is what is being read.
150
+ const across = viewMode === 'two-up' ? 2 : 1;
151
+ const columnGap = viewMode === 'two-up' ? SPREAD_GAP : PAGE_GAP;
152
+ const widthFit = (available.width - (across - 1) * columnGap) / (geometry.width * across);
153
+ const fitWidthScale = available.width ? clamp(widthFit) : 1;
154
+ // Fit page is bounded by both axes so the whole sheet lands inside the pane.
155
+ const fitPageScale = available.height
156
+ ? clamp(Math.min(widthFit, available.height / geometry.height))
157
+ : 1;
158
+ // Auto keeps a page at its true size unless the window is too narrow to hold
159
+ // it, and opens a grid zoomed out; the explicit fit modes may go past 100%.
160
+ const scale =
161
+ manualScale ??
162
+ (zoomMode === 'fit-width'
163
+ ? fitWidthScale
164
+ : zoomMode === 'fit-page'
165
+ ? fitPageScale
166
+ : Math.min(viewMode === 'grid' ? GRID_SCALE : 1, fitWidthScale));
167
+ const columns =
168
+ viewMode === 'grid'
169
+ ? gridColumns(geometry.width * scale, available.width, columnGap, pages.length)
170
+ : across;
171
+
172
+ // biome-ignore lint/correctness/useExhaustiveDependencies: state.status re-runs this once the scroll container mounts — during loading the ref is null and nothing measures.
173
+ useLayoutEffect(() => {
174
+ const el = scrollRef.current;
175
+ if (!el) return;
176
+ const measure = () => {
177
+ const width = el.clientWidth - GUTTER * 2;
178
+ const height = el.clientHeight - GUTTER * 2;
179
+ if (width > 0 && height > 0) setAvailable({ width, height });
180
+ };
181
+ measure();
182
+ const ro = new ResizeObserver(measure);
183
+ ro.observe(el);
184
+ return () => ro.disconnect();
185
+ }, [geometry.width, geometry.height, state.status]);
186
+
187
+ // Headings only exist once the pages are in the DOM at their final metrics,
188
+ // so the scan waits for fonts — a late-loading face reflows headings and
189
+ // would otherwise strand the outline on stale text.
190
+ useEffect(() => {
191
+ if (state.status !== 'ready' || measuring) return;
192
+ let cancelled = false;
193
+ (async () => {
194
+ await nextFrame();
195
+ await waitForFonts();
196
+ await nextFrame();
197
+ const root = pagesRef.current;
198
+ if (cancelled || !root) return;
199
+ scanDocument(root, doc?.meta);
200
+ })();
201
+ return () => {
202
+ cancelled = true;
203
+ };
204
+ }, [state.status, doc, measuring]);
205
+
206
+ // Counted a frame after a scroll, and recorded as a place to return to: the
207
+ // page, how far down it the reading line falls, and how far across the view is.
208
+ useEffect(() => {
209
+ const root = scrollRef.current;
210
+ const container = pagesRef.current;
211
+ if (!root || !container || pages.length === 0) return;
212
+
213
+ let frame = 0;
214
+ const update = () => {
215
+ frame = 0;
216
+ const sheets = Array.from(container.children as HTMLCollectionOf<HTMLElement>, (el) => ({
217
+ top: el.offsetTop,
218
+ height: el.offsetHeight,
219
+ }));
220
+ const page = pageInView(
221
+ sheets,
222
+ { top: root.scrollTop, height: root.clientHeight },
223
+ chosenPage.current,
224
+ );
225
+ const sheet = sheets[page - 1];
226
+ if (sheet) {
227
+ const line = root.scrollTop + root.clientHeight * READING_LINE;
228
+ place.current = {
229
+ page,
230
+ fraction: Math.min(1, Math.max(0, (line - sheet.top) / sheet.height)),
231
+ centre: (root.scrollLeft + root.clientWidth / 2) / root.scrollWidth,
232
+ };
233
+ }
234
+ setCurrentPage(page);
235
+ };
236
+ const schedule = () => {
237
+ if (frame) return;
238
+ frame = requestAnimationFrame(update);
239
+ };
240
+ // Scrolling by hand hands the counter back to the scroll position.
241
+ const release = () => {
242
+ chosenPage.current = null;
243
+ };
244
+ const onKeyDown = (e: KeyboardEvent) => {
245
+ if (SCROLL_KEYS.has(e.key)) release();
246
+ };
247
+
248
+ refreshPage.current = schedule;
249
+ schedule();
250
+ root.addEventListener('scroll', schedule, { passive: true });
251
+ root.addEventListener('wheel', release, { passive: true });
252
+ root.addEventListener('pointerdown', release);
253
+ window.addEventListener('keydown', onKeyDown);
254
+ return () => {
255
+ root.removeEventListener('scroll', schedule);
256
+ root.removeEventListener('wheel', release);
257
+ root.removeEventListener('pointerdown', release);
258
+ window.removeEventListener('keydown', onKeyDown);
259
+ if (frame) cancelAnimationFrame(frame);
260
+ refreshPage.current = () => {};
261
+ // A new page list — another document, or this one re-paginated — has
262
+ // nothing chosen yet and no place to return to.
263
+ chosenPage.current = null;
264
+ place.current = null;
265
+ };
266
+ }, [pages.length]);
267
+
268
+ /**
269
+ * A zoom, a new layout or a resized pane moves every sheet, and the scroll
270
+ * offset left behind lands somewhere else in the document — in a grid, whole
271
+ * rows away. Put the page being read back where it was: the same point of it on
272
+ * the reading line, which also keeps the counter on that page.
273
+ */
274
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `scale`, `columns` and `viewMode` are what move the sheets; the effect reads the layout they produced.
275
+ useLayoutEffect(() => {
276
+ const root = scrollRef.current;
277
+ const at = place.current;
278
+ const sheet = at
279
+ ? (pagesRef.current?.children[at.page - 1] as HTMLElement | undefined)
280
+ : undefined;
281
+ if (root && at && sheet) {
282
+ root.scrollTop =
283
+ sheet.offsetTop + at.fraction * sheet.offsetHeight - root.clientHeight * READING_LINE;
284
+ root.scrollLeft = at.centre * root.scrollWidth - root.clientWidth / 2;
285
+ chosenPage.current = at.page;
286
+ }
287
+ refreshPage.current();
288
+ }, [scale, columns, viewMode]);
289
+
290
+ const choosePage = useCallback((page: number) => {
291
+ chosenPage.current = page;
292
+ refreshPage.current();
293
+ }, []);
294
+
295
+ const scrollToPage = useCallback(
296
+ (page: number) => {
297
+ choosePage(page);
298
+ const frame = pagesRef.current?.children[page - 1];
299
+ frame?.scrollIntoView({ behavior: 'smooth', block: 'start' });
300
+ },
301
+ [choosePage],
302
+ );
303
+
304
+ // Land the heading near the top of the reading pane, not centred — the reader
305
+ // wants what follows the heading, and centring buries half of it.
306
+ const scrollToEntry = useCallback(
307
+ (entry: OutlineEntry) => {
308
+ const root = scrollRef.current;
309
+ const target = root?.querySelector<HTMLElement>(`#${CSS.escape(entry.id)}`);
310
+ if (!root || !target) {
311
+ scrollToPage(entry.page);
312
+ return;
313
+ }
314
+ choosePage(entry.page);
315
+ const offset = target.getBoundingClientRect().top - root.getBoundingClientRect().top;
316
+ root.scrollTo({ top: root.scrollTop + offset - HEADING_TOP_INSET, behavior: 'smooth' });
317
+ },
318
+ [choosePage, scrollToPage],
319
+ );
320
+
321
+ const activeOutlineId = useMemo(() => {
322
+ const onPage = outline.filter((entry) => entry.page === currentPage);
323
+ return onPage[0]?.id ?? null;
324
+ }, [outline, currentPage]);
325
+
326
+ const runDownload = async (format: DownloadFormat) => {
327
+ if (!doc || !docId || download) return;
328
+
329
+ /*
330
+ * The pages are chosen here, once, and every exporter is handed the subset
331
+ * rather than the whole document plus a range to obey. An exporter that has
332
+ * to remember to filter is an exporter that will one day forget.
333
+ */
334
+ const wanted = resolveSelection(
335
+ selection.kind === 'custom' ? { kind: 'custom', text: customRange } : selection,
336
+ pages.length,
337
+ currentPage,
338
+ );
339
+ if (!wanted) return;
340
+ const chosen = wanted.map((index) => pages[index]).filter((page) => page !== undefined);
341
+ if (chosen.length === 0) return;
342
+
343
+ setDownload({ format, percent: 0 });
344
+ try {
345
+ if (format === 'pdf') {
346
+ await exportDocAsPdf(doc, docId, chosen, (progress) =>
347
+ setDownload({ format, percent: progress.percent }),
348
+ );
349
+ } else if (format === 'html') {
350
+ await exportDocAsHtml(doc, docId, chosen);
351
+ } else if (format === 'docx') {
352
+ const { exportDocAsDocx } = await import('../lib/export-docx');
353
+ await exportDocAsDocx(doc, docId, chosen, (progress) =>
354
+ setDownload({ format, percent: progress.percent }),
355
+ );
356
+ } else {
357
+ await exportDocAsImages(doc, docId, chosen, format, (progress) =>
358
+ setDownload({ format, percent: progress.percent }),
359
+ );
360
+ }
361
+ setDownloaded(format);
362
+ setTimeout(() => setDownloaded(null), 2000);
363
+ } finally {
364
+ setDownload(null);
365
+ }
366
+ };
367
+
368
+ /* What the menu is about to do, so nobody has to count commas themselves. */
369
+ const chosenPages = describeSelection(
370
+ selection.kind === 'custom' ? { kind: 'custom', text: customRange } : selection,
371
+ pages.length,
372
+ currentPage,
373
+ );
374
+
375
+ const zoom = (delta: number) => {
376
+ setManualScale((prev) =>
377
+ Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(((prev ?? scale) + delta).toFixed(2)))),
378
+ );
379
+ };
380
+
381
+ const fitTo = (mode: 'fit-width' | 'fit-page') => {
382
+ setManualScale(null);
383
+ setZoomMode(mode);
384
+ };
385
+
386
+ const actualSize = () => {
387
+ setZoomMode('auto');
388
+ setManualScale(1);
389
+ };
390
+
391
+ // Each layout opens at its own natural zoom: a scale picked for one column is
392
+ // the wrong size for a spread, and a grid is a zoom-out by definition.
393
+ const changeViewMode = (mode: ViewMode) => {
394
+ if (mode === viewMode) return;
395
+ setViewMode(mode);
396
+ setManualScale(null);
397
+ setZoomMode('auto');
398
+ };
399
+
400
+ const toggleFullscreen = useCallback(() => {
401
+ if (document.fullscreenElement) {
402
+ void document.exitFullscreen();
403
+ return;
404
+ }
405
+ void rootRef.current?.requestFullscreen?.().catch(() => {});
406
+ }, []);
407
+
408
+ useEffect(() => {
409
+ const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
410
+ document.addEventListener('fullscreenchange', onChange);
411
+ return () => document.removeEventListener('fullscreenchange', onChange);
412
+ }, []);
413
+
414
+ // Tell the dev server where the reader is, so an agent can resolve "this
415
+ // page" from node_modules/.mosage/current.json. See vite/current-plugin.ts.
416
+ useEffect(() => {
417
+ if (!import.meta.hot) return;
418
+ if (!docId || !doc || pages.length === 0) return;
419
+ import.meta.hot.send('mosage:current', {
420
+ docId,
421
+ pageIndex: currentPage - 1,
422
+ totalPages: pages.length,
423
+ docTitle: doc.meta?.title ?? docId,
424
+ });
425
+ }, [docId, doc, currentPage, pages.length]);
426
+
427
+ useEffect(() => {
428
+ const onKeyDown = (e: KeyboardEvent) => {
429
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
430
+ const target = e.target as HTMLElement | null;
431
+ if (target?.closest('input, textarea, select, [contenteditable]')) return;
432
+ if (e.key === 'f' || e.key === 'F') {
433
+ e.preventDefault();
434
+ toggleFullscreen();
435
+ }
436
+ };
437
+ window.addEventListener('keydown', onKeyDown);
438
+ return () => window.removeEventListener('keydown', onKeyDown);
439
+ }, [toggleFullscreen]);
440
+
441
+ if (state.status === 'error') {
442
+ return (
443
+ <Centered>
444
+ <p className="font-medium text-sm">Could not load “{docId}”.</p>
445
+ <p className="mt-1 text-muted-foreground text-xs">{state.error.message}</p>
446
+ <BackLink />
447
+ </Centered>
448
+ );
449
+ }
450
+
451
+ if (state.status === 'loading' || !doc) {
452
+ return (
453
+ <Centered>
454
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
455
+ </Centered>
456
+ );
457
+ }
458
+
459
+ const view = (
460
+ <div ref={rootRef} className="flex h-screen flex-col bg-background text-foreground">
461
+ {/* Equal `1fr` rails put the title at the true centre of the bar rather
462
+ than the centre of what is left over, which is where a flex row would
463
+ drop it — the control cluster is many times wider than the back link.
464
+ The control rail keeps its automatic minimum — no `min-w-0` — so when
465
+ it outgrows its share the title truncates and slides instead of being
466
+ overlapped by it. */}
467
+ <header className="grid h-12 flex-none grid-cols-[1fr_minmax(0,auto)_1fr] items-center gap-3 border-b border-border px-3">
468
+ <div className="flex min-w-0 items-center">
469
+ <HeaderBackLink />
470
+ </div>
471
+
472
+ <h1 className="truncate text-center font-medium text-sm">{doc.meta?.title ?? docId}</h1>
473
+
474
+ <div className="flex items-center justify-end gap-3">
475
+ <span className="hidden items-center gap-1.5 sm:flex">
476
+ <PageJump page={currentPage} total={pages.length} onJump={scrollToPage} />
477
+ <DocSearch scrollRef={scrollRef} pagesRef={pagesRef} onFoundPage={choosePage} />
478
+ </span>
479
+
480
+ {/* Narrower than this, the header has no room left for the group and
481
+ two sheets side by side are too small to read anyway. */}
482
+ <fieldset aria-label="View mode" className={cn('hidden lg:flex', TOOL_GROUP_CLASS)}>
483
+ {VIEW_MODES.map((mode) => {
484
+ const { label, icon: Icon } = VIEW_MODE_OPTIONS[mode];
485
+ return (
486
+ <IconButton
487
+ key={mode}
488
+ label={label}
489
+ onClick={() => changeViewMode(mode)}
490
+ active={viewMode === mode}
491
+ >
492
+ <Icon className="size-3.5" />
493
+ </IconButton>
494
+ );
495
+ })}
496
+ </fieldset>
497
+
498
+ <div className={cn('flex', TOOL_GROUP_CLASS)}>
499
+ <IconButton label="Zoom out" onClick={() => zoom(-0.1)}>
500
+ <Minus className="size-3.5" />
501
+ </IconButton>
502
+ <button
503
+ type="button"
504
+ onClick={actualSize}
505
+ title="Actual size (100%)"
506
+ className="w-11 rounded text-center font-mono text-[11px] tabular-nums transition-colors hover:bg-accent"
507
+ >
508
+ {Math.round(scale * 100)}%
509
+ </button>
510
+ <IconButton label="Zoom in" onClick={() => zoom(0.1)}>
511
+ <Plus className="size-3.5" />
512
+ </IconButton>
513
+ <IconButton
514
+ label="Fit width"
515
+ onClick={() => fitTo('fit-width')}
516
+ active={manualScale === null && zoomMode === 'fit-width'}
517
+ >
518
+ <MoveHorizontal className="size-3.5" />
519
+ </IconButton>
520
+ {/* Fit-width moves the page sideways to the edges, fit-page moves it
521
+ up and down to them. Both used to be a square-ish glyph, and the
522
+ fit-page one was the same square as fullscreen — three controls,
523
+ two shapes, no way to tell which did what without clicking. */}
524
+ <IconButton
525
+ label="Fit page"
526
+ onClick={() => fitTo('fit-page')}
527
+ active={manualScale === null && zoomMode === 'fit-page'}
528
+ >
529
+ <MoveVertical className="size-3.5" />
530
+ </IconButton>
531
+ <IconButton label="Actual size (100%)" onClick={actualSize}>
532
+ <Percent className="size-3.5" />
533
+ </IconButton>
534
+ </div>
535
+
536
+ <IconButton
537
+ label={isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
538
+ onClick={toggleFullscreen}
539
+ >
540
+ {isFullscreen ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
541
+ </IconButton>
542
+
543
+ {/* The document browser normally carries this. A viewer mounted with
544
+ `showDocBrowser: false` never shows that shell, and without it a
545
+ reader has no way to change the theme at all. */}
546
+ {!appConfig.build.showDocBrowser && <ThemeToggle />}
547
+
548
+ {import.meta.env.DEV && (
549
+ <button
550
+ type="button"
551
+ onClick={() => setInspecting((on) => !on)}
552
+ title="Inspect and edit on the page"
553
+ className={cn(
554
+ 'flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-accent',
555
+ inspecting && 'border-transparent bg-[#3b82f6] text-white hover:bg-[#3b82f6]',
556
+ )}
557
+ >
558
+ <MousePointerClick className="size-3.5" />
559
+ Inspect
560
+ </button>
561
+ )}
562
+ {import.meta.env.DEV && (
563
+ <button
564
+ type="button"
565
+ onClick={() => setDesignOpen((open) => !open)}
566
+ className={cn(
567
+ 'flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs transition-colors hover:bg-accent',
568
+ designOpen && 'bg-accent',
569
+ )}
570
+ >
571
+ <Palette className="size-3.5" />
572
+ Design
573
+ </button>
574
+ )}
575
+ <Menu
576
+ trigger={(props) => (
577
+ <button
578
+ type="button"
579
+ disabled={download !== null}
580
+ className="flex items-center gap-1.5 rounded-md bg-primary px-2.5 py-1.5 text-primary-foreground text-xs transition-opacity hover:opacity-90 disabled:opacity-70 aria-expanded:opacity-90"
581
+ {...props}
582
+ >
583
+ {download ? (
584
+ <Loader2 className="size-3.5 animate-spin" />
585
+ ) : downloaded ? (
586
+ <Check className="size-3.5" />
587
+ ) : (
588
+ <Download className="size-3.5" />
589
+ )}
590
+ {download
591
+ ? `${DOWNLOAD_FORMATS.find((entry) => entry.format === download.format)?.label} ${Math.round(download.percent)}%`
592
+ : 'Download'}
593
+ </button>
594
+ )}
595
+ >
596
+ {(close) => (
597
+ <>
598
+ {/* Which pages, before which format. A reader who picks PDF and
599
+ then discovers they exported forty pages has already waited
600
+ for all forty. */}
601
+ <PageChoice
602
+ selection={selection}
603
+ custom={customRange}
604
+ currentPage={currentPage}
605
+ total={pages.length}
606
+ onSelection={setSelection}
607
+ onCustom={setCustomRange}
608
+ />
609
+ {DOWNLOAD_FORMATS.map(({ format, label, hint, icon: Icon }) => (
610
+ <MenuItem
611
+ key={format}
612
+ disabled={!chosenPages.valid}
613
+ onClick={() => {
614
+ close();
615
+ void runDownload(format);
616
+ }}
617
+ >
618
+ <Icon className="size-3.5 flex-none" />
619
+ <span className="flex-1">
620
+ {label}
621
+ <span className="block text-[10px] text-muted-foreground">{hint}</span>
622
+ </span>
623
+ </MenuItem>
624
+ ))}
625
+ </>
626
+ )}
627
+ </Menu>
628
+ </div>
629
+ </header>
630
+
631
+ <div className="flex min-h-0 flex-1">
632
+ <DocSidebar
633
+ docId={docId ?? ''}
634
+ pages={pages}
635
+ geometry={geometry}
636
+ design={doc.design}
637
+ currentPage={currentPage}
638
+ entries={outline}
639
+ activeId={activeOutlineId}
640
+ onSelectPage={scrollToPage}
641
+ onSelectEntry={scrollToEntry}
642
+ />
643
+ <div
644
+ ref={scrollRef}
645
+ data-od-viewer
646
+ className="relative min-w-0 flex-1 overflow-auto bg-canvas"
647
+ >
648
+ {/* Centred by its margins, not by the scroller: a row zoomed wider than
649
+ the pane then overflows to the right, where it can be scrolled to,
650
+ instead of equally off both edges with its left side out of reach. */}
651
+ <div
652
+ ref={pagesRef}
653
+ className="mx-auto grid w-max"
654
+ style={{
655
+ gridTemplateColumns: `repeat(${columns}, max-content)`,
656
+ columnGap,
657
+ rowGap: PAGE_GAP,
658
+ padding: `${GUTTER}px ${GUTTER}px ${GUTTER * 1.5}px`,
659
+ }}
660
+ >
661
+ {pages.map((page, index) => (
662
+ <PageFrame
663
+ key={page.key}
664
+ index={index}
665
+ total={pages.length}
666
+ geometry={geometry}
667
+ scale={scale}
668
+ design={doc.design}
669
+ // A bound document opens on a right-hand page, so spreads pair
670
+ // 2–3, 4–5 — the pairs a reader sees when the book is open.
671
+ className={viewMode === 'two-up' && index === 0 ? 'col-start-2' : undefined}
672
+ >
673
+ {page.content}
674
+ </PageFrame>
675
+ ))}
676
+ </div>
677
+ </div>
678
+ {inspecting && docId && (
679
+ <Inspector docId={docId} containerRef={scrollRef} onExit={() => setInspecting(false)} />
680
+ )}
681
+ {designOpen && <DesignPanel onClose={() => setDesignOpen(false)} />}
682
+ </div>
683
+ </div>
684
+ );
685
+
686
+ // The design panel writes back to source through the dev server, so it only
687
+ // exists while `mosage dev` is running.
688
+ if (!import.meta.env.DEV || !docId) return view;
689
+ return <DesignProvider docId={docId}>{view}</DesignProvider>;
690
+ }
691
+
692
+ /**
693
+ * 全部/此頁/自訂 —— 和列印對話框問的是同一件事,因為那是使用者已經會的問法。
694
+ *
695
+ * 自訂欄位只在被選中時出現。三個選項配一個永遠佔著位置的空欄位,會讓人以為那是
696
+ * 必填的。
697
+ */
698
+ function PageChoice({
699
+ selection,
700
+ custom,
701
+ currentPage,
702
+ total,
703
+ onSelection,
704
+ onCustom,
705
+ }: {
706
+ selection: PageSelection;
707
+ custom: string;
708
+ currentPage: number;
709
+ total: number;
710
+ onSelection: (selection: PageSelection) => void;
711
+ onCustom: (text: string) => void;
712
+ }) {
713
+ const options = [
714
+ { kind: 'all' as const, label: 'All', hint: `${total}` },
715
+ { kind: 'current' as const, label: 'This page', hint: `${currentPage}` },
716
+ { kind: 'custom' as const, label: 'Custom', hint: '' },
717
+ ];
718
+ const chosen = describeSelection(
719
+ selection.kind === 'custom' ? { kind: 'custom', text: custom } : selection,
720
+ total,
721
+ currentPage,
722
+ );
723
+
724
+ return (
725
+ <div className="border-border border-b px-1 pt-1 pb-2">
726
+ <p className="px-1 pb-1 text-[10px] text-muted-foreground uppercase tracking-wide">Pages</p>
727
+ <div className="flex gap-0.5">
728
+ {options.map((option) => (
729
+ <button
730
+ key={option.kind}
731
+ type="button"
732
+ onClick={(event) => {
733
+ event.stopPropagation();
734
+ onSelection(
735
+ option.kind === 'custom' ? { kind: 'custom', text: custom } : { kind: option.kind },
736
+ );
737
+ }}
738
+ className={cn(
739
+ 'flex-1 rounded px-2 py-1 text-[11px] transition-colors hover:bg-accent',
740
+ selection.kind === option.kind && 'bg-accent text-foreground',
741
+ )}
742
+ >
743
+ {option.label}
744
+ {option.hint && (
745
+ <span className="ml-1 font-mono text-[10px] text-muted-foreground">
746
+ {option.hint}
747
+ </span>
748
+ )}
749
+ </button>
750
+ ))}
751
+ </div>
752
+ {selection.kind === 'custom' && (
753
+ <input
754
+ value={custom}
755
+ onChange={(event) => onCustom(event.target.value)}
756
+ onClick={(event) => event.stopPropagation()}
757
+ placeholder="e.g. 1-3, 5"
758
+ aria-label="Pages to download"
759
+ aria-invalid={!chosen.valid}
760
+ className={cn(
761
+ 'mt-1.5 w-full rounded border border-border bg-transparent px-2 py-1 text-[11px] outline-none placeholder:text-muted-foreground focus:border-foreground/40',
762
+ !chosen.valid && custom !== '' && 'border-foreground/40',
763
+ )}
764
+ />
765
+ )}
766
+ <p className="px-1 pt-1.5 text-[10px] text-muted-foreground">
767
+ {chosen.valid
768
+ ? `${chosen.count} page${chosen.count === 1 ? '' : 's'} will be downloaded`
769
+ : 'Type page numbers, like 1-3, 5'}
770
+ </p>
771
+ </div>
772
+ );
773
+ }
774
+
775
+ /**
776
+ * 頁碼既是顯示也是輸入。
777
+ *
778
+ * 只在編輯時才變成受控欄位:一直受控的話,讀者捲動時每一次頁碼更新都會把游標推
779
+ * 回去,打到一半的數字就消失了。
780
+ */
781
+ function PageJump({
782
+ page,
783
+ total,
784
+ onJump,
785
+ }: {
786
+ page: number;
787
+ total: number;
788
+ onJump: (page: number) => void;
789
+ }) {
790
+ const [draft, setDraft] = useState<string | null>(null);
791
+
792
+ const commit = (raw: string) => {
793
+ const wanted = Number(raw.replace(/[0-9]/g, (d) => String(d.charCodeAt(0) - 0xff10)));
794
+ setDraft(null);
795
+ if (!Number.isFinite(wanted) || wanted < 1) return;
796
+ onJump(Math.min(total, Math.round(wanted)));
797
+ };
798
+
799
+ return (
800
+ <span className="flex items-center whitespace-nowrap font-mono text-muted-foreground text-xs tabular-nums">
801
+ <input
802
+ value={draft ?? String(page)}
803
+ onChange={(event) => setDraft(event.target.value)}
804
+ onFocus={(event) => {
805
+ setDraft(String(page));
806
+ event.target.select();
807
+ }}
808
+ onBlur={(event) => commit(event.target.value)}
809
+ onKeyDown={(event) => {
810
+ if (event.key === 'Enter') {
811
+ event.preventDefault();
812
+ event.currentTarget.blur();
813
+ }
814
+ if (event.key === 'Escape') {
815
+ setDraft(null);
816
+ event.currentTarget.blur();
817
+ }
818
+ }}
819
+ aria-label={`Page number, ${total} pages`}
820
+ title="Go to page"
821
+ inputMode="numeric"
822
+ className="w-7 rounded bg-transparent text-right outline-none transition-colors hover:bg-accent focus:bg-accent focus:text-foreground"
823
+ />
824
+ <span className="px-1">/</span>
825
+ <span>{total}</span>
826
+ </span>
827
+ );
828
+ }
829
+
830
+ function IconButton({
831
+ label,
832
+ onClick,
833
+ active = false,
834
+ children,
835
+ }: {
836
+ label: string;
837
+ onClick: () => void;
838
+ active?: boolean;
839
+ children: React.ReactNode;
840
+ }) {
841
+ return (
842
+ <button
843
+ type="button"
844
+ aria-label={label}
845
+ aria-pressed={active}
846
+ title={label}
847
+ onClick={onClick}
848
+ className={cn(
849
+ 'flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
850
+ active && 'bg-accent text-foreground',
851
+ )}
852
+ >
853
+ {children}
854
+ </button>
855
+ );
856
+ }
857
+
858
+ function Centered({ children }: { children: React.ReactNode }) {
859
+ return (
860
+ <div className="grid h-screen place-items-center bg-background text-center text-foreground">
861
+ <div>{children}</div>
862
+ </div>
863
+ );
864
+ }
865
+
866
+ function BackLink() {
867
+ const className = 'mt-4 inline-block text-muted-foreground text-xs underline';
868
+ return appConfig.home === undefined ? (
869
+ <Link to="/" className={className}>
870
+ Back to documents
871
+ </Link>
872
+ ) : (
873
+ <a href={appConfig.home} className={className}>
874
+ Back to workspace
875
+ </a>
876
+ );
877
+ }