@open-press/core 1.2.0 → 1.3.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 (76) hide show
  1. package/README.md +2 -2
  2. package/engine/cli.mjs +1 -1
  3. package/engine/commands/_shared.mjs +10 -5
  4. package/engine/commands/deploy.mjs +19 -4
  5. package/engine/commands/typecheck.mjs +1 -1
  6. package/engine/document-export.mjs +1 -1
  7. package/engine/output/page-block.mjs +11 -2
  8. package/engine/output/public-assets.mjs +41 -6
  9. package/engine/output/static-server.mjs +84 -24
  10. package/engine/react/caption-numbering.mjs +2 -2
  11. package/engine/react/comment-marker.mjs +1 -2
  12. package/engine/react/document-entry.mjs +64 -11
  13. package/engine/react/document-export.d.mts +6 -0
  14. package/engine/react/document-export.mjs +158 -28
  15. package/engine/react/mdx-compile.mjs +4 -4
  16. package/engine/react/measurement-css.mjs +3 -3
  17. package/engine/react/page-folio.mjs +37 -0
  18. package/engine/react/pagination/allocator.mjs +4 -4
  19. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  20. package/engine/react/press-tree-inspection.mjs +43 -13
  21. package/engine/react/project-asset-endpoint.mjs +45 -11
  22. package/engine/react/sources/heading-numbering.mjs +2 -2
  23. package/engine/react/sources/mdx-resolver.mjs +3 -3
  24. package/engine/react/style-discovery.mjs +60 -11
  25. package/engine/react/text-source-transform.mjs +18 -4
  26. package/engine/runtime/config.mjs +22 -22
  27. package/engine/runtime/file-utils.mjs +57 -13
  28. package/engine/runtime/inspection.mjs +40 -15
  29. package/engine/runtime/page-geometry.mjs +6 -6
  30. package/engine/runtime/source-text-tools.mjs +28 -4
  31. package/engine/runtime/source-workspace.mjs +6 -9
  32. package/engine/runtime/validation.mjs +42 -24
  33. package/package.json +1 -1
  34. package/src/openpress/app/OpenPressApp.tsx +10 -16
  35. package/src/openpress/app/OpenPressRuntime.tsx +29 -4
  36. package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
  37. package/src/openpress/core/PageFolio.tsx +115 -0
  38. package/src/openpress/core/Press.tsx +5 -10
  39. package/src/openpress/core/Slide.tsx +11 -0
  40. package/src/openpress/core/index.tsx +4 -0
  41. package/src/openpress/core/types.ts +21 -13
  42. package/src/openpress/core/useSource.ts +1 -1
  43. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  44. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  45. package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
  46. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  47. package/src/openpress/reader/index.ts +1 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  49. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  50. package/src/openpress/workbench/Workbench.tsx +46 -164
  51. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  52. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  53. package/src/openpress/workbench/actions/index.ts +1 -1
  54. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
  55. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  56. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  57. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  58. package/src/styles/openpress/app-shell.css +0 -83
  59. package/src/styles/openpress/print-route.css +1 -3
  60. package/src/styles/openpress/project-preview-panel.css +5 -783
  61. package/src/styles/openpress/public-viewer.css +7 -249
  62. package/src/styles/openpress/reader-runtime.css +0 -274
  63. package/src/styles/openpress/slide-presenter.css +150 -0
  64. package/src/styles/openpress/slide-public-viewer.css +222 -0
  65. package/src/styles/openpress/workbench-dialog.css +267 -0
  66. package/src/styles/openpress/workbench-export.css +154 -0
  67. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  68. package/src/styles/openpress/workbench-panels.css +0 -88
  69. package/src/styles/openpress/workbench-search.css +257 -0
  70. package/src/styles/openpress/workbench-toolbar.css +422 -0
  71. package/src/styles/openpress/workbench.css +34 -1263
  72. package/src/styles/openpress/workspace-gallery.css +0 -5
  73. package/src/styles/openpress.css +7 -1
  74. package/vite.config.ts +98 -25
  75. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  76. 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,
@@ -132,23 +128,13 @@ export function HtmlWorkbench({
132
128
  onDocumentEdited: onDocumentRefresh,
133
129
  });
134
130
 
135
- const selectWorkspacePage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
136
- reader.setPage(pageIndex, options);
137
- if (
138
- typeof window !== "undefined"
139
- && window.innerWidth < PUBLIC_DRAWER_BREAKPOINT
140
- && reader.rightPanelOpen
141
- ) {
142
- reader.toggleRightPanel();
143
- }
144
- };
145
-
146
- const selectWorkspaceAnchor = (anchorId: string, pageIndex?: number) => {
147
- const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex);
148
- if (targetPageIndex === null) return false;
149
- selectWorkspacePage(targetPageIndex, { behavior: "smooth" });
150
- return true;
151
- };
131
+ const { selectWorkspaceAnchor, selectWorkspacePage } = useWorkbenchNavigation({
132
+ anchorPageMap,
133
+ pages: displayPages,
134
+ rightPanelOpen: reader.rightPanelOpen,
135
+ setPage: reader.setPage,
136
+ toggleRightPanel: reader.toggleRightPanel,
137
+ });
152
138
 
153
139
  const comments = useInspectorComments({
154
140
  workspaceMode,
@@ -244,145 +230,41 @@ export function HtmlWorkbench({
244
230
  // don't rebuild the toolbar JSX. The toolbar depends on deploy/page/zoom
245
231
  // state and inspector mode, but never on the composer draft text.
246
232
  const toolbarActions = useMemo(() => (
247
- <>
248
- {onBackToWorkspace ? (
249
- <div className="openpress-workbench-toolbar__group" aria-label="工作台導覽">
250
- <button
251
- type="button"
252
- className="openpress-workbench-toolbar-action openpress-workbench-toolbar-action--back"
253
- data-openpress-back-to-workspace
254
- onClick={onBackToWorkspace}
255
- title="回到工作台"
256
- aria-label="回到工作台"
257
- >
258
- <Home aria-hidden="true" />
259
- <span className="openpress-workbench-toolbar-action__label">工作台</span>
260
- </button>
261
- </div>
262
- ) : null}
263
- <div className="openpress-workbench-toolbar__group" aria-label="輸出">
264
- <button
265
- type="button"
266
- className="openpress-workbench-toolbar-action"
267
- data-openpress-public-export
268
- data-openpress-toolbar-expanded={deployment.pdfToolbarExpanded ? "true" : "false"}
269
- data-openpress-toolbar-active={deployment.pdfToolbarExpanded ? "true" : "false"}
270
- disabled={deployment.pdfButtonDisabled}
271
- onClick={deployment.handleOpenWorkbenchPdf}
272
- title={deployment.pdfButtonText}
273
- aria-label={deployment.pdfButtonText}
274
- >
275
- <ExternalLink aria-hidden="true" />
276
- <span className="openpress-workbench-toolbar-action__label">{deployment.pdfButtonText}</span>
277
- {deployment.pdfStatusMessage ? (
278
- <span
279
- className="openpress-dev-pdf-status"
280
- data-openpress-pdf-status={deployment.pdfActionStatus}
281
- role="status"
282
- aria-live="polite"
283
- >
284
- <span className="openpress-dev-pdf-status__spinner" aria-hidden="true" />
285
- <span>{deployment.pdfStatusMessage}</span>
286
- </span>
287
- ) : null}
288
- </button>
289
- <ExportImageControl
290
- currentPageIndex={reader.currentPageIndex}
291
- currentPageLabel={reader.currentPageLabel}
292
- pressTitle={projectIdentity.name}
293
- />
294
- </div>
295
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
296
- {isSlidePress && onOpenPresentation ? (
297
- <button
298
- type="button"
299
- className="openpress-workbench-toolbar-action"
300
- data-openpress-slide-present
301
- data-openpress-toolbar-expanded="false"
302
- data-openpress-toolbar-active="false"
303
- aria-pressed="false"
304
- title="進入放映模式"
305
- aria-label="進入放映模式"
306
- onClick={() => onOpenPresentation?.(reader.currentPageIndex)}
307
- >
308
- <Play aria-hidden="true" />
309
- <span className="openpress-workbench-toolbar-action__label">放映</span>
310
- </button>
311
- ) : null}
312
- <button
313
- type="button"
314
- className="openpress-workbench-page-geometry"
315
- data-openpress-page-geometry
316
- title={pageGeometry.title}
317
- aria-label={`頁面規格 ${pageGeometry.title}`}
318
- >
319
- <Ruler aria-hidden="true" />
320
- <span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
321
- <span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
322
- </button>
323
- <PageZoomControl
324
- scaleMode={pageViewport.scaleMode}
325
- scaleLabel={pageViewport.scaleLabel}
326
- pageLayoutMode={pageLayoutMode}
327
- onScaleModeChange={pageViewport.setScaleMode}
328
- onPageLayoutModeChange={setPageLayoutMode}
329
- />
330
- </div>
331
- <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
332
- {workspaceMode ? (
333
- <SearchControl
334
- sourceBlocksByPath={sourceBlocksByPath}
335
- onSelectPage={selectWorkspacePage}
336
- />
337
- ) : null}
338
- {workspaceMode && editStatusMessage ? (
339
- <span
340
- className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
341
- data-openpress-edit-status={inlineEditStatus.state}
342
- role="status"
343
- aria-live="polite"
344
- >
345
- {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
346
- <span>{editStatusMessage}</span>
347
- </span>
348
- ) : null}
349
- {workspaceMode ? (
350
- <button
351
- type="button"
352
- className="openpress-workbench-toolbar-action"
353
- data-openpress-inspector-toggle
354
- data-openpress-inspector-active={inspector.inspectorMode ? "true" : "false"}
355
- data-openpress-toolbar-expanded={inspectorToolbarExpanded ? "true" : "false"}
356
- data-openpress-toolbar-active={inspectorToolbarExpanded ? "true" : "false"}
357
- onClick={() => inspector.setInspectorMode(!inspector.inspectorMode)}
358
- aria-pressed={inspector.inspectorMode}
359
- title={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
360
- aria-label={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
361
- >
362
- <MousePointer2 aria-hidden="true" />
363
- <span className="openpress-workbench-toolbar-action__label">{inspector.inspectorMode ? "註解中" : "註解"}</span>
364
- <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
365
- </button>
366
- ) : null}
367
- {workspaceMode && inspector.inspectorMode ? (
368
- <span
369
- className="openpress-dev-inspector-status"
370
- role="status"
371
- aria-live="polite"
372
- data-openpress-inspector-comment-status={comments.inspectorCommentStatus}
373
- >
374
- {comments.inspectorCommentStatusMessage}
375
- </span>
376
- ) : null}
377
- {deployment.localDeployEnabled ? (
378
- <DeploymentControl
379
- info={deployment.currentDeploymentInfo}
380
- status={deployment.status}
381
- onDeploy={deployment.handleDeploy}
382
- />
383
- ) : null}
384
- </div>
385
- </>
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
+ />
386
268
  ), [
387
269
  comments.inspectorCommentStatus,
388
270
  comments.inspectorCommentStatusMessage,
@@ -394,8 +276,9 @@ export function HtmlWorkbench({
394
276
  deployment.pdfButtonDisabled,
395
277
  deployment.pdfButtonText,
396
278
  deployment.pdfStatusMessage,
397
- deployment.pdfToolbarExpanded,
398
279
  deployment.status,
280
+ displayPages,
281
+ document.theme,
399
282
  workspaceMode,
400
283
  editStatusMessage,
401
284
  inlineEditStatus.state,
@@ -416,7 +299,6 @@ export function HtmlWorkbench({
416
299
  onBackToWorkspace,
417
300
  onOpenPresentation,
418
301
  reader.currentPageIndex,
419
- reader.currentPageLabel,
420
302
  projectIdentity.name,
421
303
  ]);
422
304
 
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  export * from "./deploymentStatusModel";
2
2
  export * from "./DeploymentControl";
3
- export * from "./ExportImageControl";
3
+ export * from "./ExportControl";
4
4
  export * from "./PageZoomControl";
5
5
  export * from "./SearchControl";
6
6
  export * from "./useDeploymentWorkbench";
@@ -54,7 +54,12 @@ export function useDeploymentWorkbench({ deploymentInfo, pressSlug = null }: Use
54
54
  }
55
55
  setStatus("deploying");
56
56
  try {
57
- const response = await fetch("/__openpress/deploy", { method: "POST" });
57
+ const requestBody = pressSlug ? { press: pressSlug } : {};
58
+ const response = await fetch("/__openpress/deploy", {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(requestBody),
62
+ });
58
63
  if (response.status === 404 || response.status === 405) {
59
64
  setStatus("unavailable");
60
65
  return;
@@ -96,7 +101,7 @@ export function useDeploymentWorkbench({ deploymentInfo, pressSlug = null }: Use
96
101
  console.error("OpenPress deploy unavailable", error);
97
102
  setStatus("unavailable");
98
103
  }
99
- }, [status, currentDeploymentInfo.configured]);
104
+ }, [status, currentDeploymentInfo.configured, pressSlug]);
100
105
 
101
106
  const handleOpenLatestLocalPdf = useCallback(async () => {
102
107
  if (pdfActionStatus === "generating") return;
@@ -0,0 +1,42 @@
1
+ import { useCallback } from "react";
2
+ import { resolveAnchorPageIndex } from "../../document-model";
3
+ import { PUBLIC_DRAWER_BREAKPOINT, type DisplayPage } from "../../reader";
4
+
5
+ type SetPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
6
+
7
+ export function useWorkbenchNavigation({
8
+ anchorPageMap,
9
+ pages,
10
+ rightPanelOpen,
11
+ setPage,
12
+ toggleRightPanel,
13
+ }: {
14
+ anchorPageMap: Map<string, number>;
15
+ pages: DisplayPage[];
16
+ rightPanelOpen: boolean;
17
+ setPage: SetPage;
18
+ toggleRightPanel: () => void;
19
+ }) {
20
+ const selectWorkspacePage = useCallback((pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
21
+ setPage(pageIndex, options);
22
+ if (
23
+ typeof window !== "undefined"
24
+ && window.innerWidth < PUBLIC_DRAWER_BREAKPOINT
25
+ && rightPanelOpen
26
+ ) {
27
+ toggleRightPanel();
28
+ }
29
+ }, [rightPanelOpen, setPage, toggleRightPanel]);
30
+
31
+ const selectWorkspaceAnchor = useCallback((anchorId: string, pageIndex?: number) => {
32
+ const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, pages.length, anchorId, pageIndex);
33
+ if (targetPageIndex === null) return false;
34
+ selectWorkspacePage(targetPageIndex, { behavior: "smooth" });
35
+ return true;
36
+ }, [anchorPageMap, pages.length, selectWorkspacePage]);
37
+
38
+ return {
39
+ selectWorkspaceAnchor,
40
+ selectWorkspacePage,
41
+ };
42
+ }