@open-press/core 1.1.2 → 1.1.4
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 +1 -1
- package/engine/commands/_shared.mjs +13 -2
- package/engine/commands/image.mjs +11 -1
- package/engine/output/chrome-pdf.mjs +155 -52
- 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/inspection.mjs +65 -7
- package/engine/runtime/page-selector.mjs +87 -0
- package/engine/runtime/source-text-tools.mjs +71 -6
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +97 -28
- 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/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 +7 -0
- 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 +1 -0
- package/src/styles/openpress/workbench.css +149 -0
|
@@ -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.
|
|
@@ -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), []);
|
|
@@ -10,6 +10,13 @@ export function isWorkspaceModeLocation(location: Pick<Location, "hostname" | "p
|
|
|
10
10
|
return segments.length === 2 && segments[1] === "preview";
|
|
11
11
|
}
|
|
12
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";
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
export function isPrintModeLocation(location: Pick<Location, "search">) {
|
|
14
21
|
return new URLSearchParams(location.search).has("print");
|
|
15
22
|
}
|
|
@@ -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
|
+
}
|
|
@@ -32,6 +32,8 @@ type DocumentWithCaretFromPoint = Document & {
|
|
|
32
32
|
|
|
33
33
|
const EDITABLE_SELECTOR = "[data-openpress-editable-block='true']";
|
|
34
34
|
const SOURCE_SELECTOR = "[data-openpress-source-editable-block='true']";
|
|
35
|
+
const EDITABLE_OBJECT_TEXT_SELECTOR = "[data-openpress-object-kind='text'][data-openpress-object-source]";
|
|
36
|
+
const EDITABLE_SOURCE_TARGET_SELECTOR = `[data-openpress-block-id], ${EDITABLE_OBJECT_TEXT_SELECTOR}`;
|
|
35
37
|
const SAVED_EDIT_STATE_RESET_DELAY_MS = 900;
|
|
36
38
|
const UNSAFE_EDITABLE_CHILDREN = [
|
|
37
39
|
"a",
|
|
@@ -50,7 +52,6 @@ const UNSAFE_EDITABLE_CHILDREN = [
|
|
|
50
52
|
"ul",
|
|
51
53
|
"video",
|
|
52
54
|
].join(",");
|
|
53
|
-
const EDITABLE_COMPONENT_CAPTION_NAMES = new Set(["MediaFigure", "ImageFigure"]);
|
|
54
55
|
|
|
55
56
|
export function useInlineDocumentEditor({
|
|
56
57
|
enabled,
|
|
@@ -233,7 +234,7 @@ function markEditableElements(
|
|
|
233
234
|
sourceBlockMap: Record<string, SourceBlock>,
|
|
234
235
|
markedElements: Set<HTMLElement>,
|
|
235
236
|
) {
|
|
236
|
-
root.querySelectorAll<HTMLElement>(
|
|
237
|
+
root.querySelectorAll<HTMLElement>(EDITABLE_SOURCE_TARGET_SELECTOR).forEach((element) => {
|
|
237
238
|
const sourceBlock = blockFromElement(element, sourceBlockMap);
|
|
238
239
|
if (sourceBlock?.kind === "table-row") {
|
|
239
240
|
markEditableTableCells(element, sourceBlock, markedElements);
|
|
@@ -244,6 +245,15 @@ function markEditableElements(
|
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
if (sourceBlock?.kind === "object-text") {
|
|
249
|
+
element.dataset.openpressBlockId = sourceBlock.id;
|
|
250
|
+
element.dataset.openpressInheritedBlockId = "true";
|
|
251
|
+
element.dataset.openpressEditKind = "object-text";
|
|
252
|
+
element.dataset.openpressEditName = "text";
|
|
253
|
+
markEditableTextElement(element, markedElements, { label: "編輯文字" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
247
257
|
if (isEditableTextBlockElement(element, sourceBlockMap)) {
|
|
248
258
|
markEditableTextElement(element, markedElements, {
|
|
249
259
|
label: sourceBlock?.name === "pre" ? "編輯程式碼文字" : "編輯文字",
|
|
@@ -267,7 +277,6 @@ function markEditableComponentCaption(
|
|
|
267
277
|
markedElements: Set<HTMLElement>,
|
|
268
278
|
) {
|
|
269
279
|
if (sourceBlock.kind !== "component") return false;
|
|
270
|
-
if (!EDITABLE_COMPONENT_CAPTION_NAMES.has(String(sourceBlock.name))) return false;
|
|
271
280
|
if (!sourceBlock.path || !sourceBlock.source?.line) return false;
|
|
272
281
|
|
|
273
282
|
const caption = componentElement.querySelector<HTMLElement>("figcaption");
|
|
@@ -407,7 +416,76 @@ function eventTargetElement(event: Event) {
|
|
|
407
416
|
|
|
408
417
|
function blockFromElement(element: HTMLElement, sourceBlockMap: Record<string, SourceBlock>) {
|
|
409
418
|
const blockId = element.dataset.openpressBlockId;
|
|
410
|
-
|
|
419
|
+
if (blockId && sourceBlockMap[blockId]) return sourceBlockMap[blockId];
|
|
420
|
+
return sourceBlockFromObjectElement(element);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sourceBlockFromObjectElement(element: HTMLElement): SourceBlock | undefined {
|
|
424
|
+
if (element.dataset.openpressObjectKind !== "text") return undefined;
|
|
425
|
+
const sourceRef = parseObjectSourceRef(element.dataset.openpressObjectSource);
|
|
426
|
+
if (typeof sourceRef?.path !== "string" || !sourceRef.path) return undefined;
|
|
427
|
+
const source = sourceLocationFromSourceRef(sourceRef);
|
|
428
|
+
if (!source?.line) return undefined;
|
|
429
|
+
const objectId = element.dataset.openpressObjectId || (typeof sourceRef.objectId === "string" ? sourceRef.objectId : undefined);
|
|
430
|
+
if (!objectId) return undefined;
|
|
431
|
+
return {
|
|
432
|
+
id: `object-text:${objectId}`,
|
|
433
|
+
kind: "object-text",
|
|
434
|
+
name: "text",
|
|
435
|
+
path: sourceRef.path,
|
|
436
|
+
source,
|
|
437
|
+
frameKey: element.dataset.openpressObjectFrameKey,
|
|
438
|
+
chainId: element.dataset.openpressObjectChainId,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
type ObjectSourceRefCandidate = {
|
|
443
|
+
path?: unknown;
|
|
444
|
+
objectId?: unknown;
|
|
445
|
+
source?: unknown;
|
|
446
|
+
line?: unknown;
|
|
447
|
+
column?: unknown;
|
|
448
|
+
endLine?: unknown;
|
|
449
|
+
endColumn?: unknown;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
type SourceLocationCandidate = {
|
|
453
|
+
line?: unknown;
|
|
454
|
+
column?: unknown;
|
|
455
|
+
endLine?: unknown;
|
|
456
|
+
endColumn?: unknown;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
function parseObjectSourceRef(value: string | undefined): ObjectSourceRefCandidate | undefined {
|
|
460
|
+
if (!value) return undefined;
|
|
461
|
+
try {
|
|
462
|
+
const parsed = JSON.parse(value) as unknown;
|
|
463
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
464
|
+
} catch {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function sourceLocationFromSourceRef(sourceRef: ReturnType<typeof parseObjectSourceRef>): SourceBlock["source"] | undefined {
|
|
470
|
+
if (!sourceRef) return undefined;
|
|
471
|
+
const nestedSource = sourceLocationCandidate(sourceRef.source);
|
|
472
|
+
const line = numberValue(sourceRef.line) ?? numberValue(nestedSource?.line);
|
|
473
|
+
if (line === undefined) return undefined;
|
|
474
|
+
return {
|
|
475
|
+
line,
|
|
476
|
+
column: numberValue(sourceRef.column) ?? numberValue(nestedSource?.column) ?? 1,
|
|
477
|
+
endLine: numberValue(sourceRef.endLine) ?? numberValue(nestedSource?.endLine),
|
|
478
|
+
endColumn: numberValue(sourceRef.endColumn) ?? numberValue(nestedSource?.endColumn),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function sourceLocationCandidate(value: unknown): SourceLocationCandidate | undefined {
|
|
483
|
+
if (!value || typeof value !== "object") return undefined;
|
|
484
|
+
return value;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function numberValue(value: unknown): number | undefined {
|
|
488
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
411
489
|
}
|
|
412
490
|
|
|
413
491
|
async function persistElementEdit(
|
|
@@ -417,8 +495,8 @@ async function persistElementEdit(
|
|
|
417
495
|
onStatusChange: InlineDocumentEditorOptions["onStatusChange"],
|
|
418
496
|
onDocumentEdited: InlineDocumentEditorOptions["onDocumentEdited"],
|
|
419
497
|
) {
|
|
420
|
-
const
|
|
421
|
-
const
|
|
498
|
+
const sourceBlock = blockFromElement(element, sourceBlockMap);
|
|
499
|
+
const blockId = sourceBlock?.id ?? element.dataset.openpressBlockId;
|
|
422
500
|
const preserveLineBreaks = element.dataset.openpressPreserveLineBreaks === "true";
|
|
423
501
|
const originalText = normalizeEditableText(element.dataset.openpressOriginalText ?? "", { preserveLineBreaks });
|
|
424
502
|
const nextText = normalizeEditableText(readableElementText(element), { preserveLineBreaks });
|
|
@@ -20,6 +20,8 @@ function WorkbenchShellRoot({
|
|
|
20
20
|
style,
|
|
21
21
|
devMode,
|
|
22
22
|
viewMode,
|
|
23
|
+
pressType = "pages",
|
|
24
|
+
presentationMode = false,
|
|
23
25
|
inspectorMode,
|
|
24
26
|
editMode = false,
|
|
25
27
|
leftPanelOpen,
|
|
@@ -31,6 +33,8 @@ function WorkbenchShellRoot({
|
|
|
31
33
|
style: CSSProperties;
|
|
32
34
|
devMode: boolean;
|
|
33
35
|
viewMode: string;
|
|
36
|
+
pressType?: string;
|
|
37
|
+
presentationMode?: boolean;
|
|
34
38
|
inspectorMode: boolean;
|
|
35
39
|
editMode?: boolean;
|
|
36
40
|
leftPanelOpen: boolean;
|
|
@@ -45,6 +49,7 @@ function WorkbenchShellRoot({
|
|
|
45
49
|
"reader-app openpress-reader-app openpress-public-viewer openpress-dev-public-viewer openpress-workbench-shell is-ready",
|
|
46
50
|
leftPanelOpen ? "" : "is-closed-left",
|
|
47
51
|
rightPanelOpen ? "" : "is-closed-right",
|
|
52
|
+
presentationMode ? "is-presentation-mode" : "",
|
|
48
53
|
].filter(Boolean).join(" ");
|
|
49
54
|
|
|
50
55
|
return (
|
|
@@ -54,6 +59,8 @@ function WorkbenchShellRoot({
|
|
|
54
59
|
className={shellClassName}
|
|
55
60
|
data-openpress-react-runtime="true"
|
|
56
61
|
data-openpress-view-mode={viewMode}
|
|
62
|
+
data-openpress-press-type={pressType}
|
|
63
|
+
data-openpress-presentation-mode={presentationMode ? "on" : "off"}
|
|
57
64
|
data-openpress-inspector-mode={inspectorMode ? "on" : "off"}
|
|
58
65
|
data-openpress-edit-mode={editMode ? "on" : "off"}
|
|
59
66
|
data-openpress-workbench-shell
|