@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.
@@ -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
  );
@@ -20,6 +20,7 @@ export interface AllocationHints {
20
20
  // (props override config) until v1.0 removes config support.
21
21
  export interface PressMetadata {
22
22
  title?: string;
23
+ type?: PressProps["type"];
23
24
  page?: PressProps["page"];
24
25
  slug?: string;
25
26
  theme?: string;
@@ -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 = "span" | "div" | "section" | "article" | "figure" | "p";
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
- setLeftPanelOpen((open) => (open && !shouldOpenLeftPanel() ? false : open));
39
- setRightPanelOpen((open) => (open && !shouldOpenRightPanel() ? false : open));
40
- onAfterResize?.();
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" | "search">) {
6
- return isLocalWorkspaceHost(location.hostname) && new URLSearchParams(location.search).has("dev");
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
+ }