@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.
- package/README.md +2 -2
- package/engine/cli.mjs +1 -1
- package/engine/commands/_shared.mjs +10 -5
- package/engine/commands/deploy.mjs +19 -4
- package/engine/commands/typecheck.mjs +1 -1
- package/engine/document-export.mjs +1 -1
- package/engine/output/page-block.mjs +11 -2
- package/engine/output/public-assets.mjs +41 -6
- package/engine/output/static-server.mjs +84 -24
- package/engine/react/caption-numbering.mjs +2 -2
- package/engine/react/comment-marker.mjs +1 -2
- package/engine/react/document-entry.mjs +64 -11
- package/engine/react/document-export.d.mts +6 -0
- package/engine/react/document-export.mjs +158 -28
- package/engine/react/mdx-compile.mjs +4 -4
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/page-folio.mjs +37 -0
- package/engine/react/pagination/allocator.mjs +4 -4
- package/engine/react/pipeline/frame-measurement.mjs +34 -16
- package/engine/react/press-tree-inspection.mjs +43 -13
- package/engine/react/project-asset-endpoint.mjs +45 -11
- package/engine/react/sources/heading-numbering.mjs +2 -2
- package/engine/react/sources/mdx-resolver.mjs +3 -3
- package/engine/react/style-discovery.mjs +60 -11
- package/engine/react/text-source-transform.mjs +18 -4
- package/engine/runtime/config.mjs +22 -22
- package/engine/runtime/file-utils.mjs +57 -13
- package/engine/runtime/inspection.mjs +40 -15
- package/engine/runtime/page-geometry.mjs +6 -6
- package/engine/runtime/source-text-tools.mjs +28 -4
- package/engine/runtime/source-workspace.mjs +6 -9
- package/engine/runtime/validation.mjs +42 -24
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +10 -16
- package/src/openpress/app/OpenPressRuntime.tsx +29 -4
- package/src/openpress/app/WorkspaceGalleryPage.tsx +1 -1
- package/src/openpress/core/PageFolio.tsx +115 -0
- package/src/openpress/core/Press.tsx +5 -10
- package/src/openpress/core/Slide.tsx +11 -0
- package/src/openpress/core/index.tsx +4 -0
- package/src/openpress/core/types.ts +21 -13
- package/src/openpress/core/useSource.ts +1 -1
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
- package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
- package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
- 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/workbench/Workbench.tsx +46 -164
- package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
- package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
- package/src/openpress/workbench/actions/index.ts +1 -1
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
- package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
- 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 +98 -25
- package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
- package/src/styles/openpress/media-workspace.css +0 -230
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
// Shape of /openpress/workspace.json — the reader fetches this on
|
|
2
2
|
// boot to decide between gallery routing (multi-Press) and direct
|
|
3
|
-
// load (single Press). One entry per
|
|
4
|
-
//
|
|
5
|
-
// Single-Press workspaces emit one entry with slug = "" and the
|
|
6
|
-
// legacy /openpress/document.json path. Multi-Press emits one entry
|
|
7
|
-
// per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
|
|
3
|
+
// load (single Press). One entry per discovered Press folder.
|
|
8
4
|
import type { PressType } from "./documentTypes";
|
|
9
5
|
|
|
10
6
|
export interface WorkspaceManifest {
|
|
@@ -16,13 +12,12 @@ export interface WorkspaceManifest {
|
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
export interface WorkspaceManifestPress {
|
|
19
|
-
// Slug for this Press.
|
|
20
|
-
// (legacy root); slug-shaped string for multi-Press.
|
|
15
|
+
// Slug for this Press. Matches the folder-convention Press slug.
|
|
21
16
|
slug: string;
|
|
22
17
|
// <Press title="..."> prop. Required in v1.0 contract.
|
|
23
18
|
title: string;
|
|
24
|
-
// Creation mode declared by <Press type>.
|
|
25
|
-
//
|
|
19
|
+
// Creation mode declared by <Press type>. The reader uses this for
|
|
20
|
+
// mode-specific navigation affordances.
|
|
26
21
|
type: PressType;
|
|
27
22
|
// Page geometry summary. Same shape as the reader's
|
|
28
23
|
// ReaderDocument.theme — readers can show a thumb in the gallery
|
|
@@ -13,11 +13,15 @@ export function PageThumbnails({
|
|
|
13
13
|
pages,
|
|
14
14
|
currentPageIndex,
|
|
15
15
|
onSelectPage,
|
|
16
|
+
selectedPageIndexes,
|
|
17
|
+
onTogglePage,
|
|
16
18
|
theme,
|
|
17
19
|
}: {
|
|
18
20
|
pages: HtmlPageBlock[];
|
|
19
21
|
currentPageIndex: number;
|
|
20
22
|
onSelectPage: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
|
|
23
|
+
selectedPageIndexes?: ReadonlySet<number>;
|
|
24
|
+
onTogglePage?: (pageIndex: number) => void;
|
|
21
25
|
theme?: Theme;
|
|
22
26
|
}) {
|
|
23
27
|
const pageWidthPx = parsePxLength(theme?.pageWidth) ?? FALLBACK_PAGE_WIDTH_PX;
|
|
@@ -40,7 +44,15 @@ export function PageThumbnails({
|
|
|
40
44
|
page={page}
|
|
41
45
|
index={index}
|
|
42
46
|
active={index === currentPageIndex}
|
|
43
|
-
|
|
47
|
+
selected={selectedPageIndexes?.has(index) ?? false}
|
|
48
|
+
selectionMode={Boolean(selectedPageIndexes && onTogglePage)}
|
|
49
|
+
onClick={() => {
|
|
50
|
+
if (selectedPageIndexes && onTogglePage) {
|
|
51
|
+
onTogglePage(index);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
onSelectPage(index, { behavior: "smooth" });
|
|
55
|
+
}}
|
|
44
56
|
pageWidthPx={pageWidthPx}
|
|
45
57
|
pageHeightPx={pageHeightPx}
|
|
46
58
|
aspectRatio={aspectRatio}
|
|
@@ -55,6 +67,8 @@ function ThumbnailCard({
|
|
|
55
67
|
page,
|
|
56
68
|
index,
|
|
57
69
|
active,
|
|
70
|
+
selected,
|
|
71
|
+
selectionMode,
|
|
58
72
|
onClick,
|
|
59
73
|
pageWidthPx,
|
|
60
74
|
pageHeightPx,
|
|
@@ -63,6 +77,8 @@ function ThumbnailCard({
|
|
|
63
77
|
page: HtmlPageBlock;
|
|
64
78
|
index: number;
|
|
65
79
|
active: boolean;
|
|
80
|
+
selected: boolean;
|
|
81
|
+
selectionMode: boolean;
|
|
66
82
|
onClick: () => void;
|
|
67
83
|
pageWidthPx: number;
|
|
68
84
|
pageHeightPx: number;
|
|
@@ -92,7 +108,7 @@ function ThumbnailCard({
|
|
|
92
108
|
cardRef.current?.scrollIntoView({ block: "nearest" });
|
|
93
109
|
}, [active]);
|
|
94
110
|
|
|
95
|
-
const className = `openpress-thumb-card${active ? " is-active" : ""}`;
|
|
111
|
+
const className = `openpress-thumb-card${active ? " is-active" : ""}${selected ? " is-selected" : ""}`;
|
|
96
112
|
// Wrap the page HTML using the same class structure as the main
|
|
97
113
|
// reader (`.openpress-html-page > .openpress-html-page__html`) so
|
|
98
114
|
// section-scoped CSS that targets those classes still applies in
|
|
@@ -124,12 +140,14 @@ function ThumbnailCard({
|
|
|
124
140
|
return (
|
|
125
141
|
<div
|
|
126
142
|
ref={cardRef}
|
|
127
|
-
role="button"
|
|
143
|
+
role={selectionMode ? "checkbox" : "button"}
|
|
128
144
|
tabIndex={0}
|
|
129
145
|
className={className}
|
|
130
146
|
data-openpress-thumb-index={index}
|
|
131
|
-
|
|
132
|
-
aria-
|
|
147
|
+
data-openpress-thumb-selected={selectionMode ? (selected ? "true" : "false") : undefined}
|
|
148
|
+
aria-label={selectionMode ? `選取第 ${index + 1} 頁:${pageTitle}` : `前往第 ${index + 1} 頁:${pageTitle}`}
|
|
149
|
+
aria-checked={selectionMode ? selected : undefined}
|
|
150
|
+
aria-current={!selectionMode && active ? "page" : undefined}
|
|
133
151
|
onClick={onClick}
|
|
134
152
|
onKeyDown={(event) => {
|
|
135
153
|
if (event.key === "Enter" || event.key === " ") {
|
|
@@ -138,6 +156,11 @@ function ThumbnailCard({
|
|
|
138
156
|
}
|
|
139
157
|
}}
|
|
140
158
|
>
|
|
159
|
+
{selectionMode ? (
|
|
160
|
+
<span className="openpress-thumb-card__check" aria-hidden="true">
|
|
161
|
+
{selected ? "✓" : ""}
|
|
162
|
+
</span>
|
|
163
|
+
) : null}
|
|
141
164
|
<div className="openpress-thumb-card__surface" ref={surfaceRef} style={{ aspectRatio }}>
|
|
142
165
|
<div className="openpress-thumb-card__frame" style={frameStyle}>
|
|
143
166
|
<div className={pageClass} style={pageStyle} data-openpress-thumb-page="true">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
|
|
2
|
-
import { Maximize2, X } from "lucide-react";
|
|
2
|
+
import { Download, Maximize2, X } from "lucide-react";
|
|
3
3
|
import { createPageObjectEntityId } from "../document-model";
|
|
4
|
-
import type { HtmlPageBlock, ReaderDocument } from "../document-model";
|
|
4
|
+
import type { DeploymentInfo, HtmlPageBlock, ReaderDocument } from "../document-model";
|
|
5
5
|
import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
|
|
6
6
|
import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel";
|
|
7
7
|
import { usePageViewportScale } from "./usePageViewportScale";
|
|
@@ -13,11 +13,13 @@ export function SlidePresentationPage({
|
|
|
13
13
|
pages,
|
|
14
14
|
style,
|
|
15
15
|
onExitPresentation,
|
|
16
|
+
deploymentInfo,
|
|
16
17
|
}: {
|
|
17
18
|
document: ReaderDocument;
|
|
18
19
|
pages: HtmlPageBlock[];
|
|
19
20
|
style: CSSProperties;
|
|
20
21
|
onExitPresentation?: (pageIndex: number) => void;
|
|
22
|
+
deploymentInfo?: DeploymentInfo;
|
|
21
23
|
}) {
|
|
22
24
|
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
23
25
|
const stageRef = useRef<HTMLElement | null>(null);
|
|
@@ -35,6 +37,8 @@ export function SlidePresentationPage({
|
|
|
35
37
|
pageContainerRef: sourceContainerRef,
|
|
36
38
|
pageCount: pages.length,
|
|
37
39
|
layoutMode: "single",
|
|
40
|
+
initialScaleMode: "fit-page",
|
|
41
|
+
maxFitScale: Infinity,
|
|
38
42
|
});
|
|
39
43
|
const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)];
|
|
40
44
|
const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1);
|
|
@@ -66,13 +70,6 @@ export function SlidePresentationPage({
|
|
|
66
70
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
67
71
|
if (isEditableTarget(event.target)) return;
|
|
68
72
|
if (event.key === "Escape") {
|
|
69
|
-
// Esc is reserved for exiting browser fullscreen. The chrome HUD
|
|
70
|
-
// already exposes explicit "re-enter fullscreen" and "close"
|
|
71
|
-
// buttons; navigating out of the presenter from a stray keystroke
|
|
72
|
-
// would yank the user back to the workspace shell unexpectedly
|
|
73
|
-
// (and racily, since the same Esc that triggered the browser's
|
|
74
|
-
// fullscreen exit is also delivered to this handler with
|
|
75
|
-
// fullscreenElement already null).
|
|
76
73
|
const activeDocument = globalThis.document;
|
|
77
74
|
if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
|
|
78
75
|
event.preventDefault();
|
|
@@ -183,6 +180,11 @@ export function SlidePresentationPage({
|
|
|
183
180
|
</section>
|
|
184
181
|
|
|
185
182
|
<div className="openpress-slide-presenter__hud" aria-label="放映控制">
|
|
183
|
+
{deploymentInfo && document.meta.title ? (
|
|
184
|
+
<span className="openpress-slide-presenter__title" aria-label="簡報標題">
|
|
185
|
+
{document.meta.title}
|
|
186
|
+
</span>
|
|
187
|
+
) : null}
|
|
186
188
|
<span
|
|
187
189
|
className="openpress-slide-presenter__progress"
|
|
188
190
|
data-openpress-present-progress
|
|
@@ -200,16 +202,31 @@ export function SlidePresentationPage({
|
|
|
200
202
|
>
|
|
201
203
|
<Maximize2 aria-hidden="true" />
|
|
202
204
|
</button>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
{deploymentInfo?.pdf ? (
|
|
206
|
+
<a
|
|
207
|
+
href={deploymentInfo.pdf}
|
|
208
|
+
target="_blank"
|
|
209
|
+
rel="noopener noreferrer"
|
|
210
|
+
className="openpress-slide-presenter__button"
|
|
211
|
+
data-openpress-present-pdf
|
|
212
|
+
aria-label="下載 PDF"
|
|
213
|
+
title="下載 PDF"
|
|
214
|
+
>
|
|
215
|
+
<Download aria-hidden="true" />
|
|
216
|
+
</a>
|
|
217
|
+
) : null}
|
|
218
|
+
{onExitPresentation ? (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
className="openpress-slide-presenter__button"
|
|
222
|
+
data-openpress-present-exit
|
|
223
|
+
onClick={() => onExitPresentation(currentPageIndex)}
|
|
224
|
+
aria-label="回到工作台"
|
|
225
|
+
title="回到工作台"
|
|
226
|
+
>
|
|
227
|
+
<X aria-hidden="true" />
|
|
228
|
+
</button>
|
|
229
|
+
) : null}
|
|
213
230
|
</div>
|
|
214
231
|
</main>
|
|
215
232
|
);
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type MouseEvent as ReactMouseEvent,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { ChevronLeft, ChevronRight, Download, Maximize2, Minimize2, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
11
|
+
import { createPageObjectEntityId } from "../document-model";
|
|
12
|
+
import type { DeploymentInfo, HtmlPageBlock, ReaderDocument } from "../document-model";
|
|
13
|
+
import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
|
|
14
|
+
import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel";
|
|
15
|
+
import { usePageViewportScale } from "./usePageViewportScale";
|
|
16
|
+
import { PageThumbnails } from "./PageThumbnailsPanel";
|
|
17
|
+
|
|
18
|
+
type SlideUiMode = "chrome" | "immersive";
|
|
19
|
+
|
|
20
|
+
export function SlidePublicViewer({
|
|
21
|
+
document,
|
|
22
|
+
pages,
|
|
23
|
+
style,
|
|
24
|
+
deploymentInfo,
|
|
25
|
+
}: {
|
|
26
|
+
document: ReaderDocument;
|
|
27
|
+
pages: HtmlPageBlock[];
|
|
28
|
+
style: CSSProperties;
|
|
29
|
+
deploymentInfo?: DeploymentInfo;
|
|
30
|
+
}) {
|
|
31
|
+
const stageRef = useRef<HTMLElement | null>(null);
|
|
32
|
+
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
33
|
+
const currentPageIndexRef = useRef(0);
|
|
34
|
+
const normalizedPageCount = normalizeReaderPageCount(pages.length);
|
|
35
|
+
|
|
36
|
+
const [currentPageIndex, setCurrentPageIndex] = useState(() => {
|
|
37
|
+
if (typeof window === "undefined") return 0;
|
|
38
|
+
return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0;
|
|
39
|
+
});
|
|
40
|
+
const [uiMode, setUiMode] = useState<SlideUiMode>("chrome");
|
|
41
|
+
const [thumbPanelOpen, setThumbPanelOpen] = useState(true);
|
|
42
|
+
|
|
43
|
+
currentPageIndexRef.current = currentPageIndex;
|
|
44
|
+
|
|
45
|
+
const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)];
|
|
46
|
+
const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1);
|
|
47
|
+
const totalPageLabel = formatReaderPageNumber(normalizedPageCount);
|
|
48
|
+
const pageHtml = useMemo(() => currentPage?.html ?? "", [currentPage?.html]);
|
|
49
|
+
|
|
50
|
+
const pageViewport = usePageViewportScale({
|
|
51
|
+
stageRef,
|
|
52
|
+
pageContainerRef: sourceContainerRef,
|
|
53
|
+
pageCount: pages.length,
|
|
54
|
+
layoutMode: "single",
|
|
55
|
+
initialScaleMode: "fit-width",
|
|
56
|
+
maxFitScale: Infinity,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const setPage = useCallback(
|
|
60
|
+
(pageIndex: number) => {
|
|
61
|
+
const target = clampReaderPageIndex(pageIndex, normalizedPageCount);
|
|
62
|
+
setCurrentPageIndex(target);
|
|
63
|
+
replacePageRoute(target);
|
|
64
|
+
},
|
|
65
|
+
[normalizedPageCount],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Clamp on page count change
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setCurrentPageIndex((idx) => clampReaderPageIndex(idx, normalizedPageCount));
|
|
71
|
+
}, [normalizedPageCount]);
|
|
72
|
+
|
|
73
|
+
// Hash sync
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const sync = () => {
|
|
76
|
+
const fromHash = pageIndexFromHash(window.location.hash, normalizedPageCount);
|
|
77
|
+
if (fromHash !== null) setCurrentPageIndex(fromHash);
|
|
78
|
+
};
|
|
79
|
+
sync();
|
|
80
|
+
window.addEventListener("hashchange", sync);
|
|
81
|
+
return () => window.removeEventListener("hashchange", sync);
|
|
82
|
+
}, [normalizedPageCount]);
|
|
83
|
+
|
|
84
|
+
// Auto-enter immersive when ?fullscreen=1 is in the URL (e.g., launched from workbench play button)
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!shouldStartImmersive()) return;
|
|
87
|
+
setUiMode("immersive");
|
|
88
|
+
const root = globalThis.document.documentElement;
|
|
89
|
+
if (root?.requestFullscreen) {
|
|
90
|
+
void root.requestFullscreen().catch(() => {
|
|
91
|
+
// Fullscreen rejected — keep immersive CSS only.
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Fullscreen change
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const handler = () => {
|
|
99
|
+
setUiMode(globalThis.document.fullscreenElement ? "immersive" : "chrome");
|
|
100
|
+
};
|
|
101
|
+
globalThis.document.addEventListener("fullscreenchange", handler);
|
|
102
|
+
return () => globalThis.document.removeEventListener("fullscreenchange", handler);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
// Keyboard navigation
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
108
|
+
if (isEditableTarget(event.target)) return;
|
|
109
|
+
|
|
110
|
+
if (event.key === "Escape") {
|
|
111
|
+
const activeDoc = globalThis.document;
|
|
112
|
+
if (activeDoc.fullscreenElement && activeDoc.exitFullscreen) {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
void activeDoc.exitFullscreen();
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (event.key === "f" || event.key === "F") {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
enterImmersive();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (event.key === " " || event.code === "Space" || event.key === "ArrowRight" || event.key === "PageDown") {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
setPage(currentPageIndexRef.current + 1);
|
|
128
|
+
} else if (event.key === "ArrowLeft" || event.key === "PageUp") {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
setPage(currentPageIndexRef.current - 1);
|
|
131
|
+
} else if (event.key === "Home") {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setPage(0);
|
|
134
|
+
} else if (event.key === "End") {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
setPage(normalizedPageCount - 1);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
141
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
142
|
+
}, [normalizedPageCount, setPage]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
143
|
+
|
|
144
|
+
const enterImmersive = () => {
|
|
145
|
+
setUiMode("immersive");
|
|
146
|
+
const root = globalThis.document.documentElement;
|
|
147
|
+
if (root?.requestFullscreen) {
|
|
148
|
+
void root.requestFullscreen().catch(() => {
|
|
149
|
+
// Fullscreen rejected (e.g. gesture policy) — keep immersive CSS only.
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const exitImmersive = () => {
|
|
155
|
+
const activeDoc = globalThis.document;
|
|
156
|
+
if (activeDoc.fullscreenElement && activeDoc.exitFullscreen) {
|
|
157
|
+
void activeDoc.exitFullscreen();
|
|
158
|
+
} else {
|
|
159
|
+
setUiMode("chrome");
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleStageClick = (event: ReactMouseEvent<HTMLElement>) => {
|
|
164
|
+
if (uiMode !== "immersive") return;
|
|
165
|
+
if (event.defaultPrevented) return;
|
|
166
|
+
if (!(event.target instanceof Element)) return;
|
|
167
|
+
if (event.target.closest("a, button, input, textarea, select, [contenteditable]")) return;
|
|
168
|
+
setPage(currentPageIndexRef.current + 1);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const LeftIcon = thumbPanelOpen ? PanelLeftClose : PanelLeftOpen;
|
|
172
|
+
const leftLabel = thumbPanelOpen ? "收合縮圖面板" : "展開縮圖面板";
|
|
173
|
+
const pdfHref = deploymentInfo?.pdf;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<main
|
|
177
|
+
className="openpress-workbench openpress-reader-app openpress-slide-public"
|
|
178
|
+
style={style}
|
|
179
|
+
data-openpress-react-runtime="true"
|
|
180
|
+
data-openpress-view-mode="paged"
|
|
181
|
+
data-openpress-press-type="slides"
|
|
182
|
+
data-openpress-presentation-mode={uiMode === "immersive" ? "on" : "off"}
|
|
183
|
+
data-openpress-present-ui={uiMode}
|
|
184
|
+
aria-label={`${document.meta.title} 投影片瀏覽`}
|
|
185
|
+
>
|
|
186
|
+
{/* Top toolbar — chrome mode only */}
|
|
187
|
+
<header
|
|
188
|
+
className="openpress-workbench-toolbar openpress-slide-public__toolbar"
|
|
189
|
+
role="toolbar"
|
|
190
|
+
aria-label="投影片操作"
|
|
191
|
+
data-openpress-slide-public-toolbar
|
|
192
|
+
>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className="openpress-workbench-toolbar-panel-toggle"
|
|
196
|
+
aria-label={leftLabel}
|
|
197
|
+
title={leftLabel}
|
|
198
|
+
onClick={() => setThumbPanelOpen((v) => !v)}
|
|
199
|
+
>
|
|
200
|
+
<LeftIcon aria-hidden="true" />
|
|
201
|
+
</button>
|
|
202
|
+
|
|
203
|
+
<div className="openpress-slide-public__nav" aria-label="翻頁">
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className="openpress-workbench-toolbar-action openpress-slide-public__nav-btn"
|
|
207
|
+
onClick={() => setPage(currentPageIndex - 1)}
|
|
208
|
+
disabled={currentPageIndex === 0}
|
|
209
|
+
aria-label="上一頁"
|
|
210
|
+
title="上一頁"
|
|
211
|
+
>
|
|
212
|
+
<ChevronLeft aria-hidden="true" />
|
|
213
|
+
</button>
|
|
214
|
+
<span className="openpress-slide-public__counter" aria-live="polite" aria-label={`第 ${currentPageLabel} 頁,共 ${totalPageLabel} 頁`}>
|
|
215
|
+
{currentPageLabel} / {totalPageLabel}
|
|
216
|
+
</span>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="openpress-workbench-toolbar-action openpress-slide-public__nav-btn"
|
|
220
|
+
onClick={() => setPage(currentPageIndex + 1)}
|
|
221
|
+
disabled={currentPageIndex >= normalizedPageCount - 1}
|
|
222
|
+
aria-label="下一頁"
|
|
223
|
+
title="下一頁"
|
|
224
|
+
>
|
|
225
|
+
<ChevronRight aria-hidden="true" />
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="openpress-workbench-toolbar__content" />
|
|
230
|
+
|
|
231
|
+
<div className="openpress-workbench-toolbar__group" aria-label="視圖">
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
className="openpress-workbench-toolbar-action"
|
|
235
|
+
onClick={enterImmersive}
|
|
236
|
+
aria-label="進入全螢幕放映"
|
|
237
|
+
title="全螢幕放映 (F)"
|
|
238
|
+
>
|
|
239
|
+
<Maximize2 aria-hidden="true" />
|
|
240
|
+
</button>
|
|
241
|
+
{pdfHref ? (
|
|
242
|
+
<a
|
|
243
|
+
href={pdfHref}
|
|
244
|
+
target="_blank"
|
|
245
|
+
rel="noopener noreferrer"
|
|
246
|
+
className="openpress-workbench-toolbar-action"
|
|
247
|
+
aria-label="下載 PDF"
|
|
248
|
+
title="下載 PDF"
|
|
249
|
+
>
|
|
250
|
+
<Download aria-hidden="true" />
|
|
251
|
+
</a>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
</header>
|
|
255
|
+
|
|
256
|
+
{/* Body: thumb panel + stage */}
|
|
257
|
+
<div className="openpress-slide-public__body">
|
|
258
|
+
{/* Thumbnail panel */}
|
|
259
|
+
<aside
|
|
260
|
+
className={`openpress-slide-public__thumbs${thumbPanelOpen ? "" : " is-closed"}`}
|
|
261
|
+
aria-label="投影片縮圖"
|
|
262
|
+
data-openpress-slide-public-thumbs
|
|
263
|
+
>
|
|
264
|
+
<PageThumbnails
|
|
265
|
+
pages={pages}
|
|
266
|
+
currentPageIndex={currentPageIndex}
|
|
267
|
+
onSelectPage={setPage}
|
|
268
|
+
theme={document.theme}
|
|
269
|
+
/>
|
|
270
|
+
</aside>
|
|
271
|
+
|
|
272
|
+
{/* Main slide stage */}
|
|
273
|
+
<section
|
|
274
|
+
className="openpress-slide-public__stage"
|
|
275
|
+
aria-label="投影片檢視區"
|
|
276
|
+
onClick={handleStageClick}
|
|
277
|
+
ref={stageRef}
|
|
278
|
+
>
|
|
279
|
+
<div
|
|
280
|
+
className="reader-pages openpress-public-page openpress-slide-public__pages"
|
|
281
|
+
ref={sourceContainerRef}
|
|
282
|
+
data-openpress-public-page="true"
|
|
283
|
+
data-openpress-page-layout="single"
|
|
284
|
+
>
|
|
285
|
+
{currentPage ? (
|
|
286
|
+
<div
|
|
287
|
+
key={currentPage.id}
|
|
288
|
+
id={`page-${String(currentPage.pageNumber).padStart(2, "0")}`}
|
|
289
|
+
className="openpress-html-page"
|
|
290
|
+
data-openpress-object-id={currentPage.frameKey ? createPageObjectEntityId(currentPage.frameKey) : undefined}
|
|
291
|
+
data-openpress-page-index={currentPage.pageNumber - 1}
|
|
292
|
+
data-openpress-active="true"
|
|
293
|
+
>
|
|
294
|
+
<div className="openpress-html-page__html" dangerouslySetInnerHTML={{ __html: pageHtml }} />
|
|
295
|
+
</div>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
</section>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{/* Immersive mini HUD — fullscreen mode only */}
|
|
302
|
+
<div
|
|
303
|
+
className="openpress-slide-public__mini-hud"
|
|
304
|
+
aria-label="放映控制"
|
|
305
|
+
data-openpress-present-scale={pageViewport.scaleMode}
|
|
306
|
+
>
|
|
307
|
+
<span className="openpress-slide-public__mini-counter">
|
|
308
|
+
{currentPageLabel} / {totalPageLabel}
|
|
309
|
+
</span>
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
className="openpress-slide-public__mini-btn"
|
|
313
|
+
onClick={exitImmersive}
|
|
314
|
+
aria-label="離開全螢幕"
|
|
315
|
+
title="離開全螢幕 (Esc)"
|
|
316
|
+
>
|
|
317
|
+
<Minimize2 aria-hidden="true" />
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
</main>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
325
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
326
|
+
return Boolean(target.closest("input, textarea, select, button, [contenteditable]"));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function shouldStartImmersive() {
|
|
330
|
+
if (typeof window === "undefined") return false;
|
|
331
|
+
return new URLSearchParams(window.location.search).get("fullscreen") === "1";
|
|
332
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./PageThumbnailsPanel";
|
|
2
2
|
export * from "./PublicReaderPage";
|
|
3
3
|
export * from "./ReaderNavigationPanel";
|
|
4
|
+
export * from "./SlidePublicPage";
|
|
4
5
|
export * from "./SlidePresentationPage";
|
|
5
6
|
export * from "./pageViewportScaleModel";
|
|
6
7
|
export * from "./readerPageRegistry";
|
|
@@ -34,13 +34,15 @@ export function resolvePageViewportScale({
|
|
|
34
34
|
mode,
|
|
35
35
|
fitWidthScale,
|
|
36
36
|
fitPageScale,
|
|
37
|
+
maxFitScale = MAX_FIT_PAGE_VIEWPORT_SCALE,
|
|
37
38
|
}: {
|
|
38
39
|
mode: PageViewportScaleMode;
|
|
39
40
|
fitWidthScale: number;
|
|
40
41
|
fitPageScale: number;
|
|
42
|
+
maxFitScale?: number;
|
|
41
43
|
}) {
|
|
42
|
-
if (mode === "fit-width") return clampPageViewportScale(fitWidthScale,
|
|
43
|
-
if (mode === "fit-page") return clampPageViewportScale(fitPageScale,
|
|
44
|
+
if (mode === "fit-width") return clampPageViewportScale(fitWidthScale, maxFitScale);
|
|
45
|
+
if (mode === "fit-page") return clampPageViewportScale(fitPageScale, maxFitScale);
|
|
44
46
|
return scaleModeToFixedValue(mode);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -68,6 +70,6 @@ function scaleModeToFixedValue(mode: PageViewportScaleMode) {
|
|
|
68
70
|
|
|
69
71
|
function clampPageViewportScale(value: number, maxScale: number) {
|
|
70
72
|
if (!Number.isFinite(value)) return 1;
|
|
71
|
-
const safeMaxScale =
|
|
73
|
+
const safeMaxScale = maxScale > 0 ? maxScale : MAX_FIXED_PAGE_VIEWPORT_SCALE;
|
|
72
74
|
return Math.min(Math.max(value, MIN_PAGE_VIEWPORT_SCALE), safeMaxScale);
|
|
73
75
|
}
|
|
@@ -13,13 +13,17 @@ export function usePageViewportScale({
|
|
|
13
13
|
pageContainerRef,
|
|
14
14
|
pageCount,
|
|
15
15
|
layoutMode = "single",
|
|
16
|
+
initialScaleMode = "fit-width",
|
|
17
|
+
maxFitScale = 1,
|
|
16
18
|
}: {
|
|
17
19
|
stageRef: RefObject<HTMLElement | null>;
|
|
18
20
|
pageContainerRef: RefObject<HTMLElement | null>;
|
|
19
21
|
pageCount: number;
|
|
20
22
|
layoutMode?: PageLayoutMode;
|
|
23
|
+
initialScaleMode?: PageViewportScaleMode;
|
|
24
|
+
maxFitScale?: number;
|
|
21
25
|
}) {
|
|
22
|
-
const [scaleMode, setScaleMode] = useState<PageViewportScaleMode>(
|
|
26
|
+
const [scaleMode, setScaleMode] = useState<PageViewportScaleMode>(initialScaleMode);
|
|
23
27
|
const [scale, setScale] = useState(1);
|
|
24
28
|
|
|
25
29
|
useLayoutEffect(() => {
|
|
@@ -66,7 +70,7 @@ export function usePageViewportScale({
|
|
|
66
70
|
const fitPageScale = canonicalWidth > 0 && canonicalHeight > 0
|
|
67
71
|
? Math.min(availableWidth / canonicalWidth, availableHeight / canonicalHeight)
|
|
68
72
|
: 1;
|
|
69
|
-
const nextScale = resolvePageViewportScale({ mode: scaleMode, fitWidthScale, fitPageScale });
|
|
73
|
+
const nextScale = resolvePageViewportScale({ mode: scaleMode, fitWidthScale, fitPageScale, maxFitScale });
|
|
70
74
|
const nextScaleValue = formatPageViewportScaleValue(nextScale);
|
|
71
75
|
|
|
72
76
|
container.style.setProperty("--openpress-page-viewport-scale", nextScaleValue);
|
|
@@ -93,16 +97,16 @@ export function usePageViewportScale({
|
|
|
93
97
|
window.removeEventListener("resize", syncScale);
|
|
94
98
|
window.visualViewport?.removeEventListener("resize", syncScale);
|
|
95
99
|
};
|
|
96
|
-
}, [layoutMode, pageContainerRef, pageCount, scaleMode, stageRef]);
|
|
100
|
+
}, [layoutMode, maxFitScale, pageContainerRef, pageCount, scaleMode, stageRef]);
|
|
97
101
|
|
|
98
102
|
const scaleLabel = useMemo(
|
|
99
103
|
() => {
|
|
100
104
|
const labelScale = scaleMode.startsWith("scale-")
|
|
101
|
-
? resolvePageViewportScale({ mode: scaleMode, fitWidthScale: scale, fitPageScale: scale })
|
|
105
|
+
? resolvePageViewportScale({ mode: scaleMode, fitWidthScale: scale, fitPageScale: scale, maxFitScale })
|
|
102
106
|
: scale;
|
|
103
107
|
return formatPageViewportScaleLabel(scaleMode, labelScale);
|
|
104
108
|
},
|
|
105
|
-
[scale, scaleMode],
|
|
109
|
+
[maxFitScale, scale, scaleMode],
|
|
106
110
|
);
|
|
107
111
|
|
|
108
112
|
return {
|