@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.
- package/engine/cli.mjs +3 -3
- package/engine/commands/_shared.mjs +89 -13
- package/engine/commands/deploy.mjs +19 -4
- package/engine/commands/image.mjs +9 -3
- package/engine/commands/pdf.mjs +4 -1
- package/engine/output/chrome-pdf.mjs +102 -0
- package/engine/output/static-server.mjs +64 -17
- package/engine/react/document-export.mjs +22 -0
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +5 -1
- package/src/openpress/app/OpenPressRuntime.tsx +85 -6
- package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
- package/src/openpress/reader/PublicReaderPage.tsx +163 -74
- package/src/openpress/reader/SlidePresentationPage.tsx +37 -15
- package/src/openpress/reader/SlidePublicPage.tsx +332 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
- package/src/openpress/reader/usePageViewportScale.ts +9 -5
- package/src/openpress/reader/usePanelState.ts +14 -5
- package/src/openpress/shared/index.ts +1 -0
- package/src/openpress/shared/staticSearch.ts +174 -0
- package/src/openpress/workbench/Workbench.tsx +61 -176
- package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
- package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
- package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
- package/src/openpress/workbench/actions/index.ts +1 -1
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +21 -5
- package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
- package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
- package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
- package/src/styles/openpress/app-shell.css +0 -83
- package/src/styles/openpress/print-route.css +1 -3
- package/src/styles/openpress/project-preview-panel.css +5 -783
- package/src/styles/openpress/public-viewer.css +7 -249
- package/src/styles/openpress/reader-runtime.css +0 -274
- package/src/styles/openpress/slide-presenter.css +150 -0
- package/src/styles/openpress/slide-public-viewer.css +222 -0
- package/src/styles/openpress/workbench-dialog.css +267 -0
- package/src/styles/openpress/workbench-export.css +154 -0
- package/src/styles/openpress/workbench-inline-editor.css +128 -0
- package/src/styles/openpress/workbench-panels.css +0 -88
- package/src/styles/openpress/workbench-search.css +257 -0
- package/src/styles/openpress/workbench-toolbar.css +422 -0
- package/src/styles/openpress/workbench.css +34 -1263
- package/src/styles/openpress/workspace-gallery.css +0 -5
- package/src/styles/openpress.css +7 -1
- package/vite.config.ts +66 -17
- package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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={
|
|
382
|
+
exposeSourceData={workspaceMode}
|
|
498
383
|
inspector={inspector}
|
|
499
384
|
onInternalAnchorNavigate={selectWorkspaceAnchor}
|
|
500
385
|
pageLayoutMode={pageLayoutMode}
|
|
501
386
|
/>
|
|
502
|
-
{
|
|
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
|
-
{
|
|
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
|
+
}
|