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,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
|
+
}
|