@open-press/core 1.1.1 → 1.1.3
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/commands/dev.mjs +1 -1
- package/engine/react/document-entry.mjs +9 -1
- package/engine/react/document-export.mjs +13 -1
- package/engine/react/press-tree-inspection.mjs +2 -0
- package/engine/react/text-source-transform.mjs +175 -0
- package/engine/runtime/source-text-tools.mjs +71 -6
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +106 -24
- package/src/openpress/app/OpenPressRuntime.tsx +27 -2
- package/src/openpress/core/Press.tsx +1 -0
- package/src/openpress/core/types.ts +6 -6
- package/src/openpress/document-model/documentTypes.ts +3 -0
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -0
- package/src/openpress/reader/PageThumbnailsPanel.tsx +7 -0
- package/src/openpress/reader/SlidePresentationPage.tsx +221 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/reader/usePanelState.ts +7 -4
- package/src/openpress/shared/runtimeMode.ts +17 -2
- package/src/openpress/workbench/Workbench.tsx +30 -2
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +84 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +7 -0
- package/src/styles/openpress/reader-runtime.css +11 -53
- package/src/styles/openpress/workbench-panels.css +18 -5
- package/src/styles/openpress/workbench.css +149 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, type CSSProperties } from "react";
|
|
2
|
-
import { PrintDocument, PublicViewer } from "../reader";
|
|
3
|
-
import { isPrintModeLocation, isWorkspaceModeLocation } from "../shared";
|
|
2
|
+
import { PrintDocument, PublicViewer, SlidePresentationPage } from "../reader";
|
|
3
|
+
import { isPresentationModeLocation, isPrintModeLocation, isWorkspaceModeLocation } from "../shared";
|
|
4
4
|
import { HtmlWorkbench } from "../workbench";
|
|
5
5
|
import type {
|
|
6
6
|
DeploymentInfo,
|
|
@@ -9,10 +9,15 @@ import type {
|
|
|
9
9
|
Theme,
|
|
10
10
|
} from "../document-model";
|
|
11
11
|
|
|
12
|
+
export type OpenPressRuntimeMode = "preview" | "present";
|
|
13
|
+
|
|
12
14
|
interface OpenPressRuntimeProps {
|
|
13
15
|
document: ReaderDocument;
|
|
16
|
+
runtimeMode?: OpenPressRuntimeMode;
|
|
14
17
|
deploymentInfo?: DeploymentInfo;
|
|
15
18
|
onDocumentRefresh?: () => void | Promise<void>;
|
|
19
|
+
onOpenPresentation?: (pageIndex: number) => void;
|
|
20
|
+
onExitPresentation?: (pageIndex: number) => void;
|
|
16
21
|
// Optional — supplied by OpenPressApp when this Press was entered from
|
|
17
22
|
// a multi-Press gallery. Renders a "工作台" home button in the toolbar
|
|
18
23
|
// that returns to the gallery without a full page reload.
|
|
@@ -21,12 +26,20 @@ interface OpenPressRuntimeProps {
|
|
|
21
26
|
|
|
22
27
|
export function OpenPressRuntime({
|
|
23
28
|
document,
|
|
29
|
+
runtimeMode,
|
|
24
30
|
deploymentInfo = { online: false },
|
|
25
31
|
onDocumentRefresh,
|
|
32
|
+
onOpenPresentation,
|
|
33
|
+
onExitPresentation,
|
|
26
34
|
onBackToWorkspace,
|
|
27
35
|
}: OpenPressRuntimeProps) {
|
|
28
36
|
const style = themeToCssVariables(document.theme);
|
|
29
37
|
const htmlPages = document.blocks.filter((block): block is HtmlPageBlock => block.kind === "htmlPage");
|
|
38
|
+
const activeRuntimeMode = useMemo<OpenPressRuntimeMode>(() => {
|
|
39
|
+
if (runtimeMode) return runtimeMode;
|
|
40
|
+
if (typeof window === "undefined") return "preview";
|
|
41
|
+
return isPresentationModeLocation(window.location) ? "present" : "preview";
|
|
42
|
+
}, [runtimeMode]);
|
|
30
43
|
const workspaceMode = useMemo(() => {
|
|
31
44
|
if (typeof window === "undefined") return false;
|
|
32
45
|
return isWorkspaceModeLocation(window.location);
|
|
@@ -41,6 +54,17 @@ export function OpenPressRuntime({
|
|
|
41
54
|
return <PrintDocument document={document} pages={htmlPages} style={style} />;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
if (activeRuntimeMode === "present" && document.meta.type === "slides") {
|
|
58
|
+
return (
|
|
59
|
+
<SlidePresentationPage
|
|
60
|
+
document={document}
|
|
61
|
+
pages={htmlPages}
|
|
62
|
+
style={style}
|
|
63
|
+
onExitPresentation={onExitPresentation}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
if (!workspaceMode) {
|
|
45
69
|
return <PublicViewer document={document} pages={htmlPages} style={style} deploymentInfo={deploymentInfo} />;
|
|
46
70
|
}
|
|
@@ -53,6 +77,7 @@ export function OpenPressRuntime({
|
|
|
53
77
|
devMode={workspaceMode}
|
|
54
78
|
deploymentInfo={deploymentInfo}
|
|
55
79
|
onDocumentRefresh={onDocumentRefresh}
|
|
80
|
+
onOpenPresentation={onOpenPresentation}
|
|
56
81
|
onBackToWorkspace={onBackToWorkspace}
|
|
57
82
|
/>
|
|
58
83
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
-
import type { EditableSourceRef, ObjectEntityKind } from "../document-model/documentTypes";
|
|
2
|
+
import type { EditableSourceRef, ObjectEntityKind, PressType } from "../document-model/documentTypes";
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Frame / MdxArea / Press primitives
|
|
@@ -52,6 +52,9 @@ export interface PressProps {
|
|
|
52
52
|
// Document title. Required in 1.0. Used for PDF metadata, HTML <title>,
|
|
53
53
|
// OG tags, and the Workspace gallery / tab-bar label.
|
|
54
54
|
title?: string;
|
|
55
|
+
// Creation mode. Pages are source-driven with MDX allocation; slides are
|
|
56
|
+
// explicit one-frame-per-page documents. Defaults to "pages".
|
|
57
|
+
type?: PressType;
|
|
55
58
|
// Page geometry preset name or a custom geometry object. Optional;
|
|
56
59
|
// workspace default applies if not set.
|
|
57
60
|
page?: "a4" | "social-square" | "slide-16-9" | PageGeometry;
|
|
@@ -118,7 +121,7 @@ export type BaseCalloutProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
|
|
|
118
121
|
children: ReactNode;
|
|
119
122
|
};
|
|
120
123
|
|
|
121
|
-
export type ObjectEntityElement =
|
|
124
|
+
export type ObjectEntityElement = keyof HTMLElementTagNameMap;
|
|
122
125
|
|
|
123
126
|
export type ObjectEntityProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
|
|
124
127
|
as?: ObjectEntityElement;
|
|
@@ -135,9 +138,7 @@ export type ObjectEntityProps = Omit<HTMLAttributes<HTMLElement>, "children"> &
|
|
|
135
138
|
children?: ReactNode;
|
|
136
139
|
};
|
|
137
140
|
|
|
138
|
-
export type TextProps = Omit<ObjectEntityProps, "kind"
|
|
139
|
-
as?: "span" | "div" | "p";
|
|
140
|
-
};
|
|
141
|
+
export type TextProps = Omit<ObjectEntityProps, "kind">;
|
|
141
142
|
|
|
142
143
|
// ---------------------------------------------------------------------------
|
|
143
144
|
// Source descriptors and resolved sources
|
|
@@ -233,4 +234,3 @@ export interface ResolvedSource {
|
|
|
233
234
|
// Per-frame, per-chain, ordered list of React nodes assigned to each
|
|
234
235
|
// MdxArea by area index.
|
|
235
236
|
export type FrameAllocation = Record<string, Record<string, ReactNode[]>>;
|
|
236
|
-
|
|
@@ -18,6 +18,8 @@ export interface ReaderDocument {
|
|
|
18
18
|
blocks: HtmlPageBlock[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export type PressType = "pages" | "slides";
|
|
22
|
+
|
|
21
23
|
export interface DocumentSource {
|
|
22
24
|
type: string;
|
|
23
25
|
contentDir?: string;
|
|
@@ -57,6 +59,7 @@ export interface SourceBlock {
|
|
|
57
59
|
|
|
58
60
|
export interface DocumentMeta {
|
|
59
61
|
title: string;
|
|
62
|
+
type?: PressType;
|
|
60
63
|
subtitle?: string;
|
|
61
64
|
organization?: string;
|
|
62
65
|
version?: string;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Single-Press workspaces emit one entry with slug = "" and the
|
|
6
6
|
// legacy /openpress/document.json path. Multi-Press emits one entry
|
|
7
7
|
// per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
|
|
8
|
+
import type { PressType } from "./documentTypes";
|
|
8
9
|
|
|
9
10
|
export interface WorkspaceManifest {
|
|
10
11
|
version: 1;
|
|
@@ -20,6 +21,9 @@ export interface WorkspaceManifestPress {
|
|
|
20
21
|
slug: string;
|
|
21
22
|
// <Press title="..."> prop. Required in v1.0 contract.
|
|
22
23
|
title: string;
|
|
24
|
+
// Creation mode declared by <Press type>. Defaults to "pages" for older
|
|
25
|
+
// documents. The reader uses this for mode-specific navigation affordances.
|
|
26
|
+
type: PressType;
|
|
23
27
|
// Page geometry summary. Same shape as the reader's
|
|
24
28
|
// ReaderDocument.theme — readers can show a thumb in the gallery
|
|
25
29
|
// without loading the full document.json.
|
|
@@ -69,6 +69,7 @@ function ThumbnailCard({
|
|
|
69
69
|
aspectRatio: string;
|
|
70
70
|
}) {
|
|
71
71
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
72
73
|
const [scale, setScale] = useState<number | null>(null);
|
|
73
74
|
|
|
74
75
|
useEffect(() => {
|
|
@@ -86,6 +87,11 @@ function ThumbnailCard({
|
|
|
86
87
|
return () => ro.disconnect();
|
|
87
88
|
}, [pageWidthPx, pageHeightPx]);
|
|
88
89
|
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!active) return;
|
|
92
|
+
cardRef.current?.scrollIntoView({ block: "nearest" });
|
|
93
|
+
}, [active]);
|
|
94
|
+
|
|
89
95
|
const className = `openpress-thumb-card${active ? " is-active" : ""}`;
|
|
90
96
|
// Wrap the page HTML using the same class structure as the main
|
|
91
97
|
// reader (`.openpress-html-page > .openpress-html-page__html`) so
|
|
@@ -117,6 +123,7 @@ function ThumbnailCard({
|
|
|
117
123
|
|
|
118
124
|
return (
|
|
119
125
|
<div
|
|
126
|
+
ref={cardRef}
|
|
120
127
|
role="button"
|
|
121
128
|
tabIndex={0}
|
|
122
129
|
className={className}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
|
|
2
|
+
import { Maximize2, X } from "lucide-react";
|
|
3
|
+
import { createPageObjectEntityId } from "../document-model";
|
|
4
|
+
import type { HtmlPageBlock, ReaderDocument } from "../document-model";
|
|
5
|
+
import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
|
|
6
|
+
import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel";
|
|
7
|
+
import { usePageViewportScale } from "./usePageViewportScale";
|
|
8
|
+
|
|
9
|
+
type PresentationUiMode = "chrome" | "immersive";
|
|
10
|
+
|
|
11
|
+
export function SlidePresentationPage({
|
|
12
|
+
document,
|
|
13
|
+
pages,
|
|
14
|
+
style,
|
|
15
|
+
onExitPresentation,
|
|
16
|
+
}: {
|
|
17
|
+
document: ReaderDocument;
|
|
18
|
+
pages: HtmlPageBlock[];
|
|
19
|
+
style: CSSProperties;
|
|
20
|
+
onExitPresentation?: (pageIndex: number) => void;
|
|
21
|
+
}) {
|
|
22
|
+
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
23
|
+
const stageRef = useRef<HTMLElement | null>(null);
|
|
24
|
+
const currentPageIndexRef = useRef(0);
|
|
25
|
+
const normalizedPageCount = normalizeReaderPageCount(pages.length);
|
|
26
|
+
const [currentPageIndex, setCurrentPageIndex] = useState(() => {
|
|
27
|
+
if (typeof window === "undefined") return 0;
|
|
28
|
+
return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0;
|
|
29
|
+
});
|
|
30
|
+
const [uiMode, setUiMode] = useState<PresentationUiMode>(() => (
|
|
31
|
+
shouldStartImmersive() ? "immersive" : "chrome"
|
|
32
|
+
));
|
|
33
|
+
const pageViewport = usePageViewportScale({
|
|
34
|
+
stageRef,
|
|
35
|
+
pageContainerRef: sourceContainerRef,
|
|
36
|
+
pageCount: pages.length,
|
|
37
|
+
layoutMode: "single",
|
|
38
|
+
});
|
|
39
|
+
const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)];
|
|
40
|
+
const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1);
|
|
41
|
+
const totalPageLabel = formatReaderPageNumber(normalizedPageCount);
|
|
42
|
+
const setPage = useCallback((pageIndex: number) => {
|
|
43
|
+
const target = clampReaderPageIndex(pageIndex, normalizedPageCount);
|
|
44
|
+
setCurrentPageIndex(target);
|
|
45
|
+
replacePageRoute(target);
|
|
46
|
+
}, [normalizedPageCount]);
|
|
47
|
+
|
|
48
|
+
currentPageIndexRef.current = currentPageIndex;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setCurrentPageIndex((idx) => clampReaderPageIndex(idx, normalizedPageCount));
|
|
52
|
+
}, [normalizedPageCount]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const syncPageFromHash = () => {
|
|
56
|
+
const fromHash = pageIndexFromHash(window.location.hash, normalizedPageCount);
|
|
57
|
+
if (fromHash !== null) setCurrentPageIndex(fromHash);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
syncPageFromHash();
|
|
61
|
+
window.addEventListener("hashchange", syncPageFromHash);
|
|
62
|
+
return () => window.removeEventListener("hashchange", syncPageFromHash);
|
|
63
|
+
}, [normalizedPageCount]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
67
|
+
if (isEditableTarget(event.target)) return;
|
|
68
|
+
if (event.key === "Escape") {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
const activeDocument = globalThis.document;
|
|
71
|
+
if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
|
|
72
|
+
void activeDocument.exitFullscreen();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
onExitPresentation?.(currentPageIndexRef.current);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (event.key === " " || event.code === "Space") {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
setPage(currentPageIndexRef.current + 1);
|
|
81
|
+
} else if (event.key === "ArrowRight" || event.key === "PageDown") {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
setPage(currentPageIndexRef.current + 1);
|
|
84
|
+
} else if (event.key === "ArrowLeft" || event.key === "PageUp") {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
setPage(currentPageIndexRef.current - 1);
|
|
87
|
+
} else if (event.key === "Home") {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setPage(0);
|
|
90
|
+
} else if (event.key === "End") {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
setPage(normalizedPageCount - 1);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
97
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
98
|
+
}, [normalizedPageCount, onExitPresentation, setPage]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const handleFullscreenChange = () => {
|
|
102
|
+
setUiMode(globalThis.document.fullscreenElement ? "immersive" : "chrome");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
globalThis.document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
106
|
+
return () => globalThis.document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!shouldStartImmersive()) return;
|
|
111
|
+
enterImmersive({ keepOnFailure: true });
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const handleStageClick = (event: ReactMouseEvent<HTMLElement>) => {
|
|
115
|
+
if (event.defaultPrevented) return;
|
|
116
|
+
if (!(event.target instanceof Element)) return;
|
|
117
|
+
if (event.target.closest("a, button, input, textarea, select, [contenteditable]")) return;
|
|
118
|
+
setPage(currentPageIndex + 1);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleFullscreen = () => {
|
|
122
|
+
enterImmersive({ keepOnFailure: false });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const enterImmersive = ({ keepOnFailure }: { keepOnFailure: boolean }) => {
|
|
126
|
+
const stage = stageRef.current;
|
|
127
|
+
if (!stage?.requestFullscreen) {
|
|
128
|
+
setUiMode("immersive");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setUiMode("immersive");
|
|
132
|
+
void stage.requestFullscreen().catch(() => {
|
|
133
|
+
if (!keepOnFailure) setUiMode("chrome");
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const pageHtml = useMemo(() => currentPage?.html ?? "", [currentPage?.html]);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<main
|
|
141
|
+
className="openpress-workbench openpress-reader-app openpress-slide-presenter"
|
|
142
|
+
style={style}
|
|
143
|
+
data-openpress-react-runtime="true"
|
|
144
|
+
data-openpress-view-mode="paged"
|
|
145
|
+
data-openpress-press-type="slides"
|
|
146
|
+
data-openpress-presentation-mode="on"
|
|
147
|
+
data-openpress-present-ui={uiMode}
|
|
148
|
+
data-openpress-slide-presenter
|
|
149
|
+
aria-label={`${document.meta.title} 放映模式`}
|
|
150
|
+
>
|
|
151
|
+
<section
|
|
152
|
+
className="openpress-slide-presenter__stage"
|
|
153
|
+
data-openpress-present-stage
|
|
154
|
+
aria-label="投影片放映區"
|
|
155
|
+
onClick={handleStageClick}
|
|
156
|
+
>
|
|
157
|
+
<main className="reader-stage" tabIndex={-1} ref={stageRef}>
|
|
158
|
+
<div
|
|
159
|
+
className="reader-pages openpress-public-page openpress-slide-presenter__pages"
|
|
160
|
+
ref={sourceContainerRef}
|
|
161
|
+
data-openpress-public-page="true"
|
|
162
|
+
data-openpress-page-layout="single"
|
|
163
|
+
>
|
|
164
|
+
{currentPage ? (
|
|
165
|
+
<div
|
|
166
|
+
key={currentPage.id}
|
|
167
|
+
id={`page-${String(currentPage.pageNumber).padStart(2, "0")}`}
|
|
168
|
+
className="openpress-html-page"
|
|
169
|
+
data-openpress-object-id={currentPage.frameKey ? createPageObjectEntityId(currentPage.frameKey) : undefined}
|
|
170
|
+
data-openpress-page-index={currentPage.pageNumber - 1}
|
|
171
|
+
data-openpress-active="true"
|
|
172
|
+
>
|
|
173
|
+
<div className="openpress-html-page__html" dangerouslySetInnerHTML={{ __html: pageHtml }} />
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
</main>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
<div className="openpress-slide-presenter__hud" aria-label="放映控制">
|
|
181
|
+
<span
|
|
182
|
+
className="openpress-slide-presenter__progress"
|
|
183
|
+
data-openpress-present-progress
|
|
184
|
+
data-openpress-present-scale={pageViewport.scaleMode}
|
|
185
|
+
>
|
|
186
|
+
{currentPageLabel} / {totalPageLabel}
|
|
187
|
+
</span>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="openpress-slide-presenter__button"
|
|
191
|
+
data-openpress-present-fullscreen
|
|
192
|
+
onClick={handleFullscreen}
|
|
193
|
+
aria-label="進入全螢幕"
|
|
194
|
+
title="進入全螢幕"
|
|
195
|
+
>
|
|
196
|
+
<Maximize2 aria-hidden="true" />
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
className="openpress-slide-presenter__button"
|
|
201
|
+
data-openpress-present-exit
|
|
202
|
+
onClick={() => onExitPresentation?.(currentPageIndex)}
|
|
203
|
+
aria-label="回到工作台"
|
|
204
|
+
title="回到工作台"
|
|
205
|
+
>
|
|
206
|
+
<X aria-hidden="true" />
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</main>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
214
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
215
|
+
return Boolean(target.closest("input, textarea, select, button, [contenteditable]"));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function shouldStartImmersive() {
|
|
219
|
+
if (typeof window === "undefined") return false;
|
|
220
|
+
return new URLSearchParams(window.location.search).get("fullscreen") === "1";
|
|
221
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./PageThumbnailsPanel";
|
|
2
2
|
export * from "./PublicReaderPage";
|
|
3
3
|
export * from "./ReaderNavigationPanel";
|
|
4
|
+
export * from "./SlidePresentationPage";
|
|
4
5
|
export * from "./pageViewportScaleModel";
|
|
5
6
|
export * from "./readerPageRegistry";
|
|
6
7
|
export * from "./readerPageRoute";
|
|
@@ -35,9 +35,12 @@ export function usePanelState({
|
|
|
35
35
|
if (typeof window === "undefined") return undefined;
|
|
36
36
|
|
|
37
37
|
const handleResize = () => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const closeLeftPanel = leftPanelOpen && !shouldOpenLeftPanel();
|
|
39
|
+
const closeRightPanel = rightPanelOpen && !shouldOpenRightPanel();
|
|
40
|
+
|
|
41
|
+
if (closeLeftPanel) setLeftPanelOpen(false);
|
|
42
|
+
if (closeRightPanel) setRightPanelOpen(false);
|
|
43
|
+
if (closeLeftPanel || closeRightPanel) onAfterResize?.();
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
handleResize();
|
|
@@ -47,7 +50,7 @@ export function usePanelState({
|
|
|
47
50
|
window.removeEventListener("resize", handleResize);
|
|
48
51
|
window.visualViewport?.removeEventListener("resize", handleResize);
|
|
49
52
|
};
|
|
50
|
-
}, [shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
|
|
53
|
+
}, [leftPanelOpen, rightPanelOpen, shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
|
|
51
54
|
|
|
52
55
|
const toggleLeftPanel = useCallback(() => setLeftPanelOpen((open) => !open), []);
|
|
53
56
|
const toggleRightPanel = useCallback(() => setRightPanelOpen((open) => !open), []);
|
|
@@ -2,10 +2,25 @@ export function isLocalWorkspaceHost(hostname: string) {
|
|
|
2
2
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
export function isWorkspaceModeLocation(location: Pick<Location, "hostname" | "
|
|
6
|
-
|
|
5
|
+
export function isWorkspaceModeLocation(location: Pick<Location, "hostname" | "pathname">) {
|
|
6
|
+
if (!isLocalWorkspaceHost(location.hostname)) return false;
|
|
7
|
+
const pathname = normalizePathname(location.pathname);
|
|
8
|
+
if (pathname === "workspace") return true;
|
|
9
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
10
|
+
return segments.length === 2 && segments[1] === "preview";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isPresentationModeLocation(location: Pick<Location, "hostname" | "pathname">) {
|
|
14
|
+
if (!isLocalWorkspaceHost(location.hostname)) return false;
|
|
15
|
+
const pathname = normalizePathname(location.pathname);
|
|
16
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
17
|
+
return segments.length === 2 && segments[1] === "present";
|
|
7
18
|
}
|
|
8
19
|
|
|
9
20
|
export function isPrintModeLocation(location: Pick<Location, "search">) {
|
|
10
21
|
return new URLSearchParams(location.search).has("print");
|
|
11
22
|
}
|
|
23
|
+
|
|
24
|
+
function normalizePathname(pathname: string) {
|
|
25
|
+
return pathname.replace(/^\/+|\/+$/g, "");
|
|
26
|
+
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
useState,
|
|
5
5
|
type CSSProperties,
|
|
6
6
|
} from "react";
|
|
7
|
-
import { ExternalLink, Home, MousePointer2, Ruler } from "lucide-react";
|
|
7
|
+
import { ExternalLink, Home, MousePointer2, Play, Ruler } from "lucide-react";
|
|
8
8
|
import {
|
|
9
9
|
getProjectIdentity,
|
|
10
10
|
resolveAnchorPageIndex,
|
|
@@ -55,6 +55,7 @@ export function HtmlWorkbench({
|
|
|
55
55
|
deploymentInfo,
|
|
56
56
|
onDocumentRefresh,
|
|
57
57
|
onBackToWorkspace,
|
|
58
|
+
onOpenPresentation,
|
|
58
59
|
extraControlPanels,
|
|
59
60
|
}: {
|
|
60
61
|
document: ReaderDocument;
|
|
@@ -64,6 +65,7 @@ export function HtmlWorkbench({
|
|
|
64
65
|
deploymentInfo: DeploymentInfo;
|
|
65
66
|
onDocumentRefresh?: () => void | Promise<void>;
|
|
66
67
|
onBackToWorkspace?: () => void;
|
|
68
|
+
onOpenPresentation?: (pageIndex: number) => void;
|
|
67
69
|
// Append extra panels into the right-side control panel. Built-in panels
|
|
68
70
|
// (pending comments + project entry) render first; extra panels render
|
|
69
71
|
// after them in the supplied order.
|
|
@@ -99,6 +101,8 @@ export function HtmlWorkbench({
|
|
|
99
101
|
const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
|
|
100
102
|
|
|
101
103
|
const projectIdentity = getProjectIdentity(document.meta);
|
|
104
|
+
const pressType = normalizePressType(document.meta.type);
|
|
105
|
+
const isSlidePress = pressType === "slides";
|
|
102
106
|
const pageGeometry = formatPageGeometrySpec(document.theme);
|
|
103
107
|
const inspectorSelectionLabel = formatInspectorSelection(
|
|
104
108
|
inspector.selectedBlock,
|
|
@@ -284,6 +288,22 @@ export function HtmlWorkbench({
|
|
|
284
288
|
/>
|
|
285
289
|
</div>
|
|
286
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}
|
|
287
307
|
<button
|
|
288
308
|
type="button"
|
|
289
309
|
className="openpress-workbench-page-geometry"
|
|
@@ -386,8 +406,10 @@ export function HtmlWorkbench({
|
|
|
386
406
|
pageViewport.scaleMode,
|
|
387
407
|
pageViewport.setScaleMode,
|
|
388
408
|
selectWorkspacePage,
|
|
409
|
+
isSlidePress,
|
|
389
410
|
sourceBlocksByPath,
|
|
390
411
|
onBackToWorkspace,
|
|
412
|
+
onOpenPresentation,
|
|
391
413
|
reader.currentPageIndex,
|
|
392
414
|
reader.currentPageLabel,
|
|
393
415
|
projectIdentity.name,
|
|
@@ -398,6 +420,8 @@ export function HtmlWorkbench({
|
|
|
398
420
|
style={style}
|
|
399
421
|
devMode={devMode}
|
|
400
422
|
viewMode={viewMode}
|
|
423
|
+
pressType={pressType}
|
|
424
|
+
presentationMode={false}
|
|
401
425
|
inspectorMode={inspector.inspectorMode}
|
|
402
426
|
editMode={inlineEditEnabled}
|
|
403
427
|
leftPanelOpen={reader.leftPanelOpen}
|
|
@@ -418,7 +442,7 @@ export function HtmlWorkbench({
|
|
|
418
442
|
{projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
|
|
419
443
|
</section>
|
|
420
444
|
|
|
421
|
-
{bookmarks.length > 0 ? (
|
|
445
|
+
{!isSlidePress && bookmarks.length > 0 ? (
|
|
422
446
|
<section
|
|
423
447
|
id="openpress-bookmarks"
|
|
424
448
|
className="openpress-panel-section openpress-panel-section--bookmarks"
|
|
@@ -504,3 +528,7 @@ function formatInlineEditStatus(status: InlineDocumentEditStatus) {
|
|
|
504
528
|
if (status.state === "failed") return "儲存失敗";
|
|
505
529
|
return "";
|
|
506
530
|
}
|
|
531
|
+
|
|
532
|
+
function normalizePressType(value: ReaderDocument["meta"]["type"]) {
|
|
533
|
+
return value === "slides" ? "slides" : "pages";
|
|
534
|
+
}
|