@open-press/core 1.1.4 → 1.2.1

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 (51) hide show
  1. package/engine/cli.mjs +3 -3
  2. package/engine/commands/_shared.mjs +89 -13
  3. package/engine/commands/deploy.mjs +19 -4
  4. package/engine/commands/image.mjs +9 -3
  5. package/engine/commands/pdf.mjs +4 -1
  6. package/engine/output/chrome-pdf.mjs +102 -0
  7. package/engine/output/static-server.mjs +64 -17
  8. package/engine/react/document-export.mjs +22 -0
  9. package/package.json +1 -1
  10. package/src/openpress/app/OpenPressApp.tsx +5 -1
  11. package/src/openpress/app/OpenPressRuntime.tsx +85 -6
  12. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  13. package/src/openpress/reader/PublicReaderPage.tsx +163 -74
  14. package/src/openpress/reader/SlidePresentationPage.tsx +37 -15
  15. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  16. package/src/openpress/reader/index.ts +1 -0
  17. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  18. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  19. package/src/openpress/reader/usePanelState.ts +14 -5
  20. package/src/openpress/shared/index.ts +1 -0
  21. package/src/openpress/shared/staticSearch.ts +174 -0
  22. package/src/openpress/workbench/Workbench.tsx +61 -176
  23. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  24. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  25. package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
  26. package/src/openpress/workbench/actions/index.ts +1 -1
  27. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +21 -5
  28. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  29. package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
  30. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  31. package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
  32. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  33. package/src/styles/openpress/app-shell.css +0 -83
  34. package/src/styles/openpress/print-route.css +1 -3
  35. package/src/styles/openpress/project-preview-panel.css +5 -783
  36. package/src/styles/openpress/public-viewer.css +7 -249
  37. package/src/styles/openpress/reader-runtime.css +0 -274
  38. package/src/styles/openpress/slide-presenter.css +150 -0
  39. package/src/styles/openpress/slide-public-viewer.css +222 -0
  40. package/src/styles/openpress/workbench-dialog.css +267 -0
  41. package/src/styles/openpress/workbench-export.css +154 -0
  42. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  43. package/src/styles/openpress/workbench-panels.css +0 -88
  44. package/src/styles/openpress/workbench-search.css +257 -0
  45. package/src/styles/openpress/workbench-toolbar.css +422 -0
  46. package/src/styles/openpress/workbench.css +34 -1263
  47. package/src/styles/openpress/workspace-gallery.css +0 -5
  48. package/src/styles/openpress.css +7 -1
  49. package/vite.config.ts +66 -17
  50. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  51. package/src/styles/openpress/media-workspace.css +0 -230
@@ -4,10 +4,8 @@ import {
4
4
  useState,
5
5
  type CSSProperties,
6
6
  } from "react";
7
- import { ExternalLink, Home, MousePointer2, Play, Ruler } from "lucide-react";
8
7
  import {
9
8
  getProjectIdentity,
10
- resolveAnchorPageIndex,
11
9
  type DeploymentInfo,
12
10
  type HtmlPageBlock,
13
11
  type ReaderDocument,
@@ -34,14 +32,12 @@ import {
34
32
  type InlineDocumentSourceTarget,
35
33
  } from "./document";
36
34
  import {
37
- DeploymentControl,
38
- ExportImageControl,
39
- PageZoomControl,
40
- SearchControl,
41
35
  useDeploymentWorkbench,
42
36
  } from "./actions";
43
37
  import { PendingCommentsPanel, WorkbenchControlPanel, type WorkbenchPanel } from "./panels";
44
38
  import { WorkbenchShell } from "./shell";
39
+ import { WorkbenchToolbarActions } from "./shell/WorkbenchToolbarActions";
40
+ import { useWorkbenchNavigation } from "./hooks/useWorkbenchNavigation";
45
41
  import {
46
42
  formatPageGeometrySpec,
47
43
  formatInspectorSelection,
@@ -51,8 +47,9 @@ export function HtmlWorkbench({
51
47
  document,
52
48
  pages,
53
49
  style,
54
- devMode,
50
+ workspaceMode,
55
51
  deploymentInfo,
52
+ pressSlug = null,
56
53
  onDocumentRefresh,
57
54
  onBackToWorkspace,
58
55
  onOpenPresentation,
@@ -61,8 +58,12 @@ export function HtmlWorkbench({
61
58
  document: ReaderDocument;
62
59
  pages: Array<HtmlPageBlock>;
63
60
  style: CSSProperties;
64
- devMode: boolean;
61
+ workspaceMode: boolean;
65
62
  deploymentInfo: DeploymentInfo;
63
+ // Active Press slug — threaded down to useDeploymentWorkbench so the
64
+ // local PDF export endpoint can pick the right Press in multi-Press
65
+ // workspaces. Null when the workspace is at the gallery root.
66
+ pressSlug?: string | null;
66
67
  onDocumentRefresh?: () => void | Promise<void>;
67
68
  onBackToWorkspace?: () => void;
68
69
  onOpenPresentation?: (pageIndex: number) => void;
@@ -83,7 +84,7 @@ export function HtmlWorkbench({
83
84
  sourceBlocksByPath,
84
85
  projectMentionItems,
85
86
  } = useDocumentWorkbenchModel(document, displayPages);
86
- const inspector = useInspector(document, { enabled: devMode });
87
+ const inspector = useInspector(document, { enabled: workspaceMode });
87
88
  const reader = useReaderRuntime({
88
89
  pageCount: Math.max(displayPages.length, 1),
89
90
  leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
@@ -96,7 +97,7 @@ export function HtmlWorkbench({
96
97
  pageCount: displayPages.length,
97
98
  layoutMode: pageLayoutMode,
98
99
  });
99
- const deployment = useDeploymentWorkbench({ deploymentInfo });
100
+ const deployment = useDeploymentWorkbench({ deploymentInfo, pressSlug });
100
101
  const [inlineEditStatus, setInlineEditStatus] = useState<InlineDocumentEditStatus>({ state: "idle" });
101
102
  const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
102
103
 
@@ -117,7 +118,7 @@ export function HtmlWorkbench({
117
118
  // text cursor active would (a) show the I-beam instead of the inspector
118
119
  // crosshair, (b) allow accidental text selection that paints the whole
119
120
  // page (notably covers) with the browser ::selection color.
120
- const inlineEditEnabled = devMode && !inspector.inspectorMode;
121
+ const inlineEditEnabled = workspaceMode && !inspector.inspectorMode;
121
122
  useInlineDocumentEditor({
122
123
  enabled: inlineEditEnabled,
123
124
  sourceContainerRef,
@@ -127,26 +128,16 @@ export function HtmlWorkbench({
127
128
  onDocumentEdited: onDocumentRefresh,
128
129
  });
129
130
 
130
- const selectWorkspacePage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
131
- reader.setPage(pageIndex, options);
132
- if (
133
- typeof window !== "undefined"
134
- && window.innerWidth < PUBLIC_DRAWER_BREAKPOINT
135
- && reader.rightPanelOpen
136
- ) {
137
- reader.toggleRightPanel();
138
- }
139
- };
140
-
141
- const selectWorkspaceAnchor = (anchorId: string, pageIndex?: number) => {
142
- const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex);
143
- if (targetPageIndex === null) return false;
144
- selectWorkspacePage(targetPageIndex, { behavior: "smooth" });
145
- return true;
146
- };
131
+ const { selectWorkspaceAnchor, selectWorkspacePage } = useWorkbenchNavigation({
132
+ anchorPageMap,
133
+ pages: displayPages,
134
+ rightPanelOpen: reader.rightPanelOpen,
135
+ setPage: reader.setPage,
136
+ toggleRightPanel: reader.toggleRightPanel,
137
+ });
147
138
 
148
139
  const comments = useInspectorComments({
149
- devMode,
140
+ workspaceMode,
150
141
  inspector,
151
142
  sourceBlockMap,
152
143
  sourceBlocksByPath,
@@ -239,145 +230,41 @@ export function HtmlWorkbench({
239
230
  // don't rebuild the toolbar JSX. The toolbar depends on deploy/page/zoom
240
231
  // state and inspector mode, but never on the composer draft text.
241
232
  const toolbarActions = useMemo(() => (
242
- <>
243
- {onBackToWorkspace ? (
244
- <div className="openpress-workbench-toolbar__group" aria-label="工作台導覽">
245
- <button
246
- type="button"
247
- className="openpress-workbench-toolbar-action openpress-workbench-toolbar-action--back"
248
- data-openpress-back-to-workspace
249
- onClick={onBackToWorkspace}
250
- title="回到工作台"
251
- aria-label="回到工作台"
252
- >
253
- <Home aria-hidden="true" />
254
- <span className="openpress-workbench-toolbar-action__label">工作台</span>
255
- </button>
256
- </div>
257
- ) : null}
258
- <div className="openpress-workbench-toolbar__group" aria-label="輸出">
259
- <button
260
- type="button"
261
- className="openpress-workbench-toolbar-action"
262
- data-openpress-public-export
263
- data-openpress-toolbar-expanded={deployment.pdfToolbarExpanded ? "true" : "false"}
264
- data-openpress-toolbar-active={deployment.pdfToolbarExpanded ? "true" : "false"}
265
- disabled={deployment.pdfButtonDisabled}
266
- onClick={deployment.handleOpenWorkbenchPdf}
267
- title={deployment.pdfButtonText}
268
- aria-label={deployment.pdfButtonText}
269
- >
270
- <ExternalLink aria-hidden="true" />
271
- <span className="openpress-workbench-toolbar-action__label">{deployment.pdfButtonText}</span>
272
- {deployment.pdfStatusMessage ? (
273
- <span
274
- className="openpress-dev-pdf-status"
275
- data-openpress-pdf-status={deployment.pdfActionStatus}
276
- role="status"
277
- aria-live="polite"
278
- >
279
- <span className="openpress-dev-pdf-status__spinner" aria-hidden="true" />
280
- <span>{deployment.pdfStatusMessage}</span>
281
- </span>
282
- ) : null}
283
- </button>
284
- <ExportImageControl
285
- currentPageIndex={reader.currentPageIndex}
286
- currentPageLabel={reader.currentPageLabel}
287
- pressTitle={projectIdentity.name}
288
- />
289
- </div>
290
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
291
- {isSlidePress && onOpenPresentation ? (
292
- <button
293
- type="button"
294
- className="openpress-workbench-toolbar-action"
295
- data-openpress-slide-present
296
- data-openpress-toolbar-expanded="false"
297
- data-openpress-toolbar-active="false"
298
- aria-pressed="false"
299
- title="進入放映模式"
300
- aria-label="進入放映模式"
301
- onClick={() => onOpenPresentation?.(reader.currentPageIndex)}
302
- >
303
- <Play aria-hidden="true" />
304
- <span className="openpress-workbench-toolbar-action__label">放映</span>
305
- </button>
306
- ) : null}
307
- <button
308
- type="button"
309
- className="openpress-workbench-page-geometry"
310
- data-openpress-page-geometry
311
- title={pageGeometry.title}
312
- aria-label={`頁面規格 ${pageGeometry.title}`}
313
- >
314
- <Ruler aria-hidden="true" />
315
- <span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
316
- <span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
317
- </button>
318
- <PageZoomControl
319
- scaleMode={pageViewport.scaleMode}
320
- scaleLabel={pageViewport.scaleLabel}
321
- pageLayoutMode={pageLayoutMode}
322
- onScaleModeChange={pageViewport.setScaleMode}
323
- onPageLayoutModeChange={setPageLayoutMode}
324
- />
325
- </div>
326
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
327
- {devMode ? (
328
- <SearchControl
329
- sourceBlocksByPath={sourceBlocksByPath}
330
- onSelectPage={selectWorkspacePage}
331
- />
332
- ) : null}
333
- {devMode && editStatusMessage ? (
334
- <span
335
- className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
336
- data-openpress-edit-status={inlineEditStatus.state}
337
- role="status"
338
- aria-live="polite"
339
- >
340
- {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
341
- <span>{editStatusMessage}</span>
342
- </span>
343
- ) : null}
344
- {devMode ? (
345
- <button
346
- type="button"
347
- className="openpress-workbench-toolbar-action"
348
- data-openpress-inspector-toggle
349
- data-openpress-inspector-active={inspector.inspectorMode ? "true" : "false"}
350
- data-openpress-toolbar-expanded={inspectorToolbarExpanded ? "true" : "false"}
351
- data-openpress-toolbar-active={inspectorToolbarExpanded ? "true" : "false"}
352
- onClick={() => inspector.setInspectorMode(!inspector.inspectorMode)}
353
- aria-pressed={inspector.inspectorMode}
354
- title={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
355
- aria-label={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
356
- >
357
- <MousePointer2 aria-hidden="true" />
358
- <span className="openpress-workbench-toolbar-action__label">{inspector.inspectorMode ? "註解中" : "註解"}</span>
359
- <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
360
- </button>
361
- ) : null}
362
- {devMode && inspector.inspectorMode ? (
363
- <span
364
- className="openpress-dev-inspector-status"
365
- role="status"
366
- aria-live="polite"
367
- data-openpress-inspector-comment-status={comments.inspectorCommentStatus}
368
- >
369
- {comments.inspectorCommentStatusMessage}
370
- </span>
371
- ) : null}
372
- {deployment.localDeployEnabled ? (
373
- <DeploymentControl
374
- info={deployment.currentDeploymentInfo}
375
- status={deployment.status}
376
- onDeploy={deployment.handleDeploy}
377
- />
378
- ) : null}
379
- </div>
380
- </>
233
+ <WorkbenchToolbarActions
234
+ pages={displayPages}
235
+ currentPageIndex={reader.currentPageIndex}
236
+ pressTitle={projectIdentity.name}
237
+ theme={document.theme}
238
+ workspaceMode={workspaceMode}
239
+ sourceBlocksByPath={sourceBlocksByPath}
240
+ onSelectPage={selectWorkspacePage}
241
+ onBackToWorkspace={onBackToWorkspace}
242
+ isSlidePress={isSlidePress}
243
+ onOpenPresentation={onOpenPresentation}
244
+ pageGeometry={pageGeometry}
245
+ scaleMode={pageViewport.scaleMode}
246
+ scaleLabel={pageViewport.scaleLabel}
247
+ pageLayoutMode={pageLayoutMode}
248
+ onScaleModeChange={pageViewport.setScaleMode}
249
+ onPageLayoutModeChange={setPageLayoutMode}
250
+ inlineEditStatus={inlineEditStatus}
251
+ editStatusMessage={editStatusMessage}
252
+ inspectorMode={inspector.inspectorMode}
253
+ inspectorToolbarExpanded={inspectorToolbarExpanded}
254
+ inspectorSelectionLabel={inspectorSelectionLabel}
255
+ onInspectorModeChange={inspector.setInspectorMode}
256
+ inspectorCommentStatus={comments.inspectorCommentStatus}
257
+ inspectorCommentStatusMessage={comments.inspectorCommentStatusMessage}
258
+ deploymentInfo={deployment.currentDeploymentInfo}
259
+ deploymentStatus={deployment.status}
260
+ localDeployEnabled={deployment.localDeployEnabled}
261
+ onDeploy={deployment.handleDeploy}
262
+ onExportPdf={deployment.handleOpenWorkbenchPdf}
263
+ pdfDisabled={deployment.pdfButtonDisabled}
264
+ pdfLabel={deployment.pdfButtonText}
265
+ pdfStatusMessage={deployment.pdfStatusMessage}
266
+ pdfActionStatus={deployment.pdfActionStatus}
267
+ />
381
268
  ), [
382
269
  comments.inspectorCommentStatus,
383
270
  comments.inspectorCommentStatusMessage,
@@ -389,9 +276,10 @@ export function HtmlWorkbench({
389
276
  deployment.pdfButtonDisabled,
390
277
  deployment.pdfButtonText,
391
278
  deployment.pdfStatusMessage,
392
- deployment.pdfToolbarExpanded,
393
279
  deployment.status,
394
- devMode,
280
+ displayPages,
281
+ document.theme,
282
+ workspaceMode,
395
283
  editStatusMessage,
396
284
  inlineEditStatus.state,
397
285
  inspector.inspectorMode,
@@ -411,14 +299,12 @@ export function HtmlWorkbench({
411
299
  onBackToWorkspace,
412
300
  onOpenPresentation,
413
301
  reader.currentPageIndex,
414
- reader.currentPageLabel,
415
302
  projectIdentity.name,
416
303
  ]);
417
304
 
418
305
  return (
419
306
  <WorkbenchShell
420
307
  style={style}
421
- devMode={devMode}
422
308
  viewMode={viewMode}
423
309
  pressType={pressType}
424
310
  presentationMode={false}
@@ -491,15 +377,14 @@ export function HtmlWorkbench({
491
377
  <PublicPage
492
378
  pages={displayPages}
493
379
  currentPageIndex={reader.currentPageIndex}
494
- devMode={devMode}
495
380
  sourceContainerRef={sourceContainerRef}
496
381
  registerPage={reader.registerPage}
497
- exposeSourceData={devMode}
382
+ exposeSourceData={workspaceMode}
498
383
  inspector={inspector}
499
384
  onInternalAnchorNavigate={selectWorkspaceAnchor}
500
385
  pageLayoutMode={pageLayoutMode}
501
386
  />
502
- {devMode ? (
387
+ {workspaceMode ? (
503
388
  <InlineInspectorLayer
504
389
  sourceContainerRef={sourceContainerRef}
505
390
  inspector={inspector}
@@ -508,7 +393,7 @@ export function HtmlWorkbench({
508
393
  geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}:${pageLayoutMode}`}
509
394
  />
510
395
  ) : null}
511
- {devMode ? (
396
+ {workspaceMode ? (
512
397
  <InlineSourceEditorLayer
513
398
  target={sourceEditorTarget}
514
399
  onClose={() => setSourceEditorTarget(null)}
@@ -75,9 +75,9 @@ export function DeploymentControl({
75
75
  className="openpress-workbench-toolbar-action"
76
76
  data-openpress-deploy
77
77
  data-openpress-deploy-status={kind}
78
+ data-openpress-deploy-state={status}
78
79
  data-openpress-toolbar-expanded="false"
79
80
  data-openpress-toolbar-active="false"
80
- data-deploy-status={status}
81
81
  aria-busy={busy ? "true" : "false"}
82
82
  aria-label={buttonText}
83
83
  title={description}
@@ -0,0 +1,267 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { ChevronDown, Download, FileDown, FileText, Image as ImageIcon } from "lucide-react";
3
+ import { toPng } from "html-to-image";
4
+ import type { HtmlPageBlock, Theme } from "../../document-model";
5
+ import { PageThumbnails } from "../../reader";
6
+ import { WorkbenchDialog } from "../dialog";
7
+
8
+ type ExportDialog = "none" | "pdf" | "png";
9
+ type PngExportStatus = "idle" | "exporting" | "done" | "error";
10
+
11
+ export function ExportControl({
12
+ pages,
13
+ currentPageIndex,
14
+ pressTitle,
15
+ theme,
16
+ pdfHref,
17
+ onExportPdf,
18
+ pdfDisabled = false,
19
+ pdfLabel,
20
+ pdfStatusMessage,
21
+ pdfActionStatus,
22
+ }: {
23
+ pages: HtmlPageBlock[];
24
+ currentPageIndex: number;
25
+ pressTitle: string;
26
+ theme?: Theme;
27
+ pdfHref?: string;
28
+ onExportPdf?: () => void;
29
+ pdfDisabled?: boolean;
30
+ pdfLabel?: string;
31
+ pdfStatusMessage?: string | null;
32
+ pdfActionStatus?: string;
33
+ }) {
34
+ const menuId = useId();
35
+ const pdfTitleId = useId();
36
+ const pngTitleId = useId();
37
+ const rootRef = useRef<HTMLDivElement | null>(null);
38
+ const [dropdownOpen, setDropdownOpen] = useState(false);
39
+ const [activeDialog, setActiveDialog] = useState<ExportDialog>("none");
40
+ const [pngPageIndex, setPngPageIndex] = useState(currentPageIndex);
41
+ const [selectedPngPageIndexes, setSelectedPngPageIndexes] = useState<Set<number>>(() => new Set());
42
+ const [pngStatus, setPngStatus] = useState<PngExportStatus>("idle");
43
+
44
+ useEffect(() => {
45
+ if (!dropdownOpen) return undefined;
46
+ const handlePointerDown = (event: PointerEvent) => {
47
+ if (event.target instanceof Node && rootRef.current?.contains(event.target)) return;
48
+ setDropdownOpen(false);
49
+ };
50
+ const handleKeyDown = (event: KeyboardEvent) => {
51
+ if (event.key === "Escape") setDropdownOpen(false);
52
+ };
53
+ window.addEventListener("pointerdown", handlePointerDown);
54
+ window.addEventListener("keydown", handleKeyDown);
55
+ return () => {
56
+ window.removeEventListener("pointerdown", handlePointerDown);
57
+ window.removeEventListener("keydown", handleKeyDown);
58
+ };
59
+ }, [dropdownOpen]);
60
+
61
+ const openPdf = () => { setDropdownOpen(false); setActiveDialog("pdf"); };
62
+ const openPng = () => {
63
+ setDropdownOpen(false);
64
+ setPngPageIndex(currentPageIndex);
65
+ setSelectedPngPageIndexes(createAllPageIndexSet(pages));
66
+ setPngStatus("idle");
67
+ setActiveDialog("png");
68
+ };
69
+ const closeDialog = () => setActiveDialog("none");
70
+
71
+ const togglePngPage = (pageIndex: number) => {
72
+ setSelectedPngPageIndexes((current) => {
73
+ const next = new Set(current);
74
+ if (next.has(pageIndex)) next.delete(pageIndex);
75
+ else next.add(pageIndex);
76
+ return next;
77
+ });
78
+ };
79
+
80
+ const selectAllPngPages = () => setSelectedPngPageIndexes(createAllPageIndexSet(pages));
81
+ const clearPngPages = () => setSelectedPngPageIndexes(new Set());
82
+
83
+ const handleExportPng = useCallback(async () => {
84
+ if (pngStatus === "exporting") return;
85
+ const pageIndexes = pages
86
+ .map((page) => page.pageNumber - 1)
87
+ .filter((pageIndex) => selectedPngPageIndexes.has(pageIndex));
88
+ if (pageIndexes.length === 0) return;
89
+ setPngStatus("exporting");
90
+ try {
91
+ const safeTitle = sanitizeFilename(pressTitle) || "openpress";
92
+ for (const pageIndex of pageIndexes) {
93
+ const pageEl = typeof window === "undefined"
94
+ ? null
95
+ : window.document.querySelector<HTMLElement>(`[data-openpress-page-index="${pageIndex}"]`);
96
+ if (!pageEl) throw new Error(`找不到第 ${pageIndex + 1} 頁元素`);
97
+ const dataUrl = await toPng(pageEl, { pixelRatio: 2, cacheBust: true, backgroundColor: "#ffffff" });
98
+ const safePage = String(pageIndex + 1).padStart(2, "0");
99
+ const link = window.document.createElement("a");
100
+ link.href = dataUrl;
101
+ link.download = `${safeTitle}-${safePage}.png`;
102
+ window.document.body.appendChild(link);
103
+ link.click();
104
+ link.remove();
105
+ }
106
+ setPngStatus("done");
107
+ window.setTimeout(() => setPngStatus("idle"), 1600);
108
+ } catch (error) {
109
+ console.error("[openpress] PNG export failed", error);
110
+ setPngStatus("error");
111
+ window.setTimeout(() => setPngStatus("idle"), 2400);
112
+ }
113
+ }, [pages, pngStatus, pressTitle, selectedPngPageIndexes]);
114
+
115
+ const hasPdf = Boolean(pdfHref ?? onExportPdf);
116
+ const selectedPngCount = selectedPngPageIndexes.size;
117
+ const pngButtonLabel = pngStatus === "exporting" ? "匯出中…"
118
+ : pngStatus === "done" ? "已下載"
119
+ : pngStatus === "error" ? "匯出失敗"
120
+ : selectedPngCount === 0 ? "請選擇圖片"
121
+ : `匯出 ${selectedPngCount} 張`;
122
+
123
+ return (
124
+ <div ref={rootRef} className="openpress-workbench-zoom-control-wrap" data-openpress-export-control>
125
+ <button
126
+ type="button"
127
+ className="openpress-workbench-zoom-control"
128
+ aria-label="匯出"
129
+ title="匯出"
130
+ aria-haspopup="menu"
131
+ aria-expanded={dropdownOpen}
132
+ aria-controls={dropdownOpen ? menuId : undefined}
133
+ onClick={() => setDropdownOpen((v) => !v)}
134
+ >
135
+ <FileDown aria-hidden="true" />
136
+ <span>匯出</span>
137
+ <ChevronDown className="openpress-workbench-zoom-control__chevron" aria-hidden="true" />
138
+ </button>
139
+
140
+ {dropdownOpen ? (
141
+ <div id={menuId} className="openpress-workbench-zoom-menu" role="menu" aria-label="匯出選項">
142
+ <div className="openpress-workbench-zoom-menu__section" role="group">
143
+ {hasPdf ? (
144
+ <button type="button" className="openpress-workbench-zoom-menu__item" role="menuitem" onClick={openPdf}>
145
+ <span className="openpress-workbench-zoom-menu__check" aria-hidden="true" />
146
+ <FileText aria-hidden="true" />
147
+ <span>PDF</span>
148
+ </button>
149
+ ) : null}
150
+ <button type="button" className="openpress-workbench-zoom-menu__item" role="menuitem" onClick={openPng}>
151
+ <span className="openpress-workbench-zoom-menu__check" aria-hidden="true" />
152
+ <ImageIcon aria-hidden="true" />
153
+ <span>PNG 圖片</span>
154
+ </button>
155
+ </div>
156
+ </div>
157
+ ) : null}
158
+
159
+ {activeDialog === "pdf" ? (
160
+ <WorkbenchDialog
161
+ titleId={pdfTitleId}
162
+ eyebrow="匯出"
163
+ title="PDF"
164
+ closeLabel="關閉"
165
+ className="openpress-export-dialog"
166
+ onClose={closeDialog}
167
+ footer={
168
+ pdfHref ? (
169
+ <a
170
+ href={pdfHref}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ className="openpress-export-dialog__action"
174
+ onClick={closeDialog}
175
+ >
176
+ <Download aria-hidden="true" />
177
+ <span>下載 PDF</span>
178
+ </a>
179
+ ) : onExportPdf ? (
180
+ <button
181
+ type="button"
182
+ className="openpress-export-dialog__action"
183
+ disabled={pdfDisabled}
184
+ onClick={onExportPdf}
185
+ >
186
+ <Download aria-hidden="true" />
187
+ <span>{pdfLabel ?? "生成 PDF"}</span>
188
+ {pdfStatusMessage ? (
189
+ <span
190
+ className="openpress-dev-pdf-status"
191
+ data-openpress-pdf-status={pdfActionStatus}
192
+ role="status"
193
+ aria-live="polite"
194
+ >
195
+ <span className="openpress-dev-pdf-status__spinner" aria-hidden="true" />
196
+ <span>{pdfStatusMessage}</span>
197
+ </span>
198
+ ) : null}
199
+ </button>
200
+ ) : null
201
+ }
202
+ >
203
+ <div className="openpress-export-dialog__body">
204
+ <p className="openpress-export-dialog__summary">共 {pages.length} 頁</p>
205
+ </div>
206
+ </WorkbenchDialog>
207
+ ) : null}
208
+
209
+ {activeDialog === "png" ? (
210
+ <WorkbenchDialog
211
+ titleId={pngTitleId}
212
+ eyebrow="匯出"
213
+ title="PNG 圖片"
214
+ closeLabel="關閉"
215
+ className="openpress-export-dialog openpress-export-png-dialog"
216
+ onClose={closeDialog}
217
+ footer={
218
+ <button
219
+ type="button"
220
+ className="openpress-export-dialog__action"
221
+ disabled={pngStatus === "exporting" || selectedPngCount === 0}
222
+ data-openpress-export-status={pngStatus}
223
+ onClick={handleExportPng}
224
+ >
225
+ <Download aria-hidden="true" />
226
+ <span>{pngButtonLabel}</span>
227
+ </button>
228
+ }
229
+ >
230
+ <div className="openpress-export-dialog__selection-bar">
231
+ <span>{selectedPngCount} / {pages.length} 張已選</span>
232
+ <div className="openpress-export-dialog__selection-actions">
233
+ <button type="button" onClick={selectAllPngPages}>全選</button>
234
+ <button type="button" onClick={clearPngPages}>清除</button>
235
+ </div>
236
+ </div>
237
+ <div className="openpress-export-dialog__thumbs">
238
+ <PageThumbnails
239
+ pages={pages}
240
+ currentPageIndex={pngPageIndex}
241
+ selectedPageIndexes={selectedPngPageIndexes}
242
+ onTogglePage={(idx) => {
243
+ setPngPageIndex(idx);
244
+ togglePngPage(idx);
245
+ }}
246
+ onSelectPage={(idx) => setPngPageIndex(idx)}
247
+ theme={theme}
248
+ />
249
+ </div>
250
+ </WorkbenchDialog>
251
+ ) : null}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ function sanitizeFilename(value: string): string {
257
+ return value
258
+ .replace(/[\\/:*?"<>|]+/g, "-")
259
+ .replace(/\s+/g, "-")
260
+ .replace(/-+/g, "-")
261
+ .replace(/^-+|-+$/g, "")
262
+ .slice(0, 80);
263
+ }
264
+
265
+ function createAllPageIndexSet(pages: HtmlPageBlock[]) {
266
+ return new Set(pages.map((page) => page.pageNumber - 1));
267
+ }