@open-press/core 1.1.4 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/engine/cli.mjs +3 -3
  2. package/engine/commands/_shared.mjs +89 -13
  3. package/engine/commands/deploy.mjs +19 -4
  4. package/engine/commands/image.mjs +9 -3
  5. package/engine/commands/pdf.mjs +4 -1
  6. package/engine/output/chrome-pdf.mjs +102 -0
  7. package/engine/output/static-server.mjs +64 -17
  8. package/engine/react/document-export.mjs +22 -0
  9. package/package.json +1 -1
  10. package/src/openpress/app/OpenPressApp.tsx +5 -1
  11. package/src/openpress/app/OpenPressRuntime.tsx +85 -6
  12. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  13. package/src/openpress/reader/PublicReaderPage.tsx +163 -74
  14. package/src/openpress/reader/SlidePresentationPage.tsx +37 -15
  15. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  16. package/src/openpress/reader/index.ts +1 -0
  17. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  18. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  19. package/src/openpress/reader/usePanelState.ts +14 -5
  20. package/src/openpress/shared/index.ts +1 -0
  21. package/src/openpress/shared/staticSearch.ts +174 -0
  22. package/src/openpress/workbench/Workbench.tsx +61 -176
  23. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  24. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  25. package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
  26. package/src/openpress/workbench/actions/index.ts +1 -1
  27. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +21 -5
  28. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  29. package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
  30. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  31. package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
  32. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  33. package/src/styles/openpress/app-shell.css +0 -83
  34. package/src/styles/openpress/print-route.css +1 -3
  35. package/src/styles/openpress/project-preview-panel.css +5 -783
  36. package/src/styles/openpress/public-viewer.css +7 -249
  37. package/src/styles/openpress/reader-runtime.css +0 -274
  38. package/src/styles/openpress/slide-presenter.css +150 -0
  39. package/src/styles/openpress/slide-public-viewer.css +222 -0
  40. package/src/styles/openpress/workbench-dialog.css +267 -0
  41. package/src/styles/openpress/workbench-export.css +154 -0
  42. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  43. package/src/styles/openpress/workbench-panels.css +0 -88
  44. package/src/styles/openpress/workbench-search.css +257 -0
  45. package/src/styles/openpress/workbench-toolbar.css +422 -0
  46. package/src/styles/openpress/workbench.css +34 -1263
  47. package/src/styles/openpress/workspace-gallery.css +0 -5
  48. package/src/styles/openpress.css +7 -1
  49. package/vite.config.ts +66 -17
  50. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  51. package/src/styles/openpress/media-workspace.css +0 -230
@@ -1,5 +1,5 @@
1
- import { useMemo, type CSSProperties } from "react";
2
- import { PrintDocument, PublicViewer, SlidePresentationPage } from "../reader";
1
+ import { useEffect, useMemo, useState, type CSSProperties } from "react";
2
+ import { PrintDocument, PublicViewer, SlidePublicViewer, SlidePresentationPage } from "../reader";
3
3
  import { isPresentationModeLocation, isPrintModeLocation, isWorkspaceModeLocation } from "../shared";
4
4
  import { HtmlWorkbench } from "../workbench";
5
5
  import type {
@@ -15,6 +15,12 @@ interface OpenPressRuntimeProps {
15
15
  document: ReaderDocument;
16
16
  runtimeMode?: OpenPressRuntimeMode;
17
17
  deploymentInfo?: DeploymentInfo;
18
+ // Active Press slug — supplied by OpenPressApp when the active document
19
+ // came from a multi-Press workspace. The workbench passes this through to
20
+ // useDeploymentWorkbench so the local PDF export endpoint can target the
21
+ // right Press instead of defaulting to the first one and producing a
22
+ // "0 pages observed" timeout.
23
+ activeSlug?: string;
18
24
  onDocumentRefresh?: () => void | Promise<void>;
19
25
  onOpenPresentation?: (pageIndex: number) => void;
20
26
  onExitPresentation?: (pageIndex: number) => void;
@@ -28,6 +34,7 @@ export function OpenPressRuntime({
28
34
  document,
29
35
  runtimeMode,
30
36
  deploymentInfo = { online: false },
37
+ activeSlug,
31
38
  onDocumentRefresh,
32
39
  onOpenPresentation,
33
40
  onExitPresentation,
@@ -35,19 +42,29 @@ export function OpenPressRuntime({
35
42
  }: OpenPressRuntimeProps) {
36
43
  const style = themeToCssVariables(document.theme);
37
44
  const htmlPages = document.blocks.filter((block): block is HtmlPageBlock => block.kind === "htmlPage");
45
+ // The mode flags below all read window.location synchronously. They
46
+ // would otherwise stay frozen at their mount-time values when
47
+ // OpenPressApp re-renders us in response to a client-side URL change
48
+ // (e.g. /<slug>/present -> /<slug>/preview after exiting the slide
49
+ // presenter), so the SlidePresentationPage exits to the wrong
50
+ // route-driven branch (PublicViewer instead of HtmlWorkbench) and the
51
+ // user sees the legacy public-viewer chrome until a hard reload.
52
+ // Bump a version on every pathname/search change so the memos
53
+ // re-evaluate exactly when the URL does.
54
+ const routeVersion = useLocationVersion();
38
55
  const activeRuntimeMode = useMemo<OpenPressRuntimeMode>(() => {
39
56
  if (runtimeMode) return runtimeMode;
40
57
  if (typeof window === "undefined") return "preview";
41
58
  return isPresentationModeLocation(window.location) ? "present" : "preview";
42
- }, [runtimeMode]);
59
+ }, [runtimeMode, routeVersion]);
43
60
  const workspaceMode = useMemo(() => {
44
61
  if (typeof window === "undefined") return false;
45
62
  return isWorkspaceModeLocation(window.location);
46
- }, []);
63
+ }, [routeVersion]);
47
64
  const printMode = useMemo(() => {
48
65
  if (typeof window === "undefined") return false;
49
66
  return isPrintModeLocation(window.location);
50
- }, []);
67
+ }, [routeVersion]);
51
68
 
52
69
  if (htmlPages.length > 0) {
53
70
  if (printMode) {
@@ -65,6 +82,20 @@ export function OpenPressRuntime({
65
82
  );
66
83
  }
67
84
 
85
+ if (!workspaceMode && document.meta.type === "slides") {
86
+ const slideDeploymentInfo = activeSlug
87
+ ? { ...deploymentInfo, pdf: resolvePressPdfUrl(deploymentInfo.pdf, activeSlug) }
88
+ : deploymentInfo;
89
+ return (
90
+ <SlidePublicViewer
91
+ document={document}
92
+ pages={htmlPages}
93
+ style={style}
94
+ deploymentInfo={slideDeploymentInfo}
95
+ />
96
+ );
97
+ }
98
+
68
99
  if (!workspaceMode) {
69
100
  return <PublicViewer document={document} pages={htmlPages} style={style} deploymentInfo={deploymentInfo} />;
70
101
  }
@@ -74,8 +105,9 @@ export function OpenPressRuntime({
74
105
  document={document}
75
106
  pages={htmlPages}
76
107
  style={style}
77
- devMode={workspaceMode}
108
+ workspaceMode={workspaceMode}
78
109
  deploymentInfo={deploymentInfo}
110
+ pressSlug={activeSlug ?? null}
79
111
  onDocumentRefresh={onDocumentRefresh}
80
112
  onOpenPresentation={onOpenPresentation}
81
113
  onBackToWorkspace={onBackToWorkspace}
@@ -110,6 +142,53 @@ function EmptyState({ style, workspaceMode }: { style: CSSProperties; workspaceM
110
142
  );
111
143
  }
112
144
 
145
+ // Bump a counter whenever client-side navigation changes pathname /
146
+ // search / hash, so location-derived memos in OpenPressRuntime
147
+ // re-evaluate. popstate fires on browser back/forward; we also patch
148
+ // pushState / replaceState because the SPA itself calls those when
149
+ // the user opens a Press, exits a slide presenter, or toggles between
150
+ // /<slug>/preview and /<slug>/present.
151
+ function useLocationVersion() {
152
+ const [version, setVersion] = useState(0);
153
+ useEffect(() => {
154
+ if (typeof window === "undefined") return undefined;
155
+ const bump = () => setVersion((value) => value + 1);
156
+ window.addEventListener("popstate", bump);
157
+ window.addEventListener("hashchange", bump);
158
+ const history = window.history;
159
+ const originalPushState = history.pushState.bind(history);
160
+ const originalReplaceState = history.replaceState.bind(history);
161
+ history.pushState = function patchedPushState(...args) {
162
+ const result = originalPushState(...args);
163
+ bump();
164
+ return result;
165
+ } as typeof history.pushState;
166
+ history.replaceState = function patchedReplaceState(...args) {
167
+ const result = originalReplaceState(...args);
168
+ bump();
169
+ return result;
170
+ } as typeof history.replaceState;
171
+ return () => {
172
+ window.removeEventListener("popstate", bump);
173
+ window.removeEventListener("hashchange", bump);
174
+ history.pushState = originalPushState;
175
+ history.replaceState = originalReplaceState;
176
+ };
177
+ }, []);
178
+ return version;
179
+ }
180
+
181
+ function resolvePressPdfUrl(basePdfUrl: string | undefined, slug: string): string | undefined {
182
+ if (!basePdfUrl) return undefined;
183
+ const lastSlash = basePdfUrl.lastIndexOf("/");
184
+ const dir = lastSlash >= 0 ? basePdfUrl.slice(0, lastSlash + 1) : "";
185
+ const filename = lastSlash >= 0 ? basePdfUrl.slice(lastSlash + 1) : basePdfUrl;
186
+ const dot = filename.lastIndexOf(".");
187
+ const stem = dot >= 0 ? filename.slice(0, dot) : filename;
188
+ const ext = dot >= 0 ? filename.slice(dot) : "";
189
+ return `${dir}${stem}-${slug}${ext}`;
190
+ }
191
+
113
192
  function themeToCssVariables(theme?: Theme) {
114
193
  const style: CSSProperties & Record<`--${string}`, string> = {
115
194
  "--openpress-font-family": theme?.fontFamily ?? "'Noto Sans TC', 'PingFang TC', sans-serif",
@@ -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
- onClick={() => onSelectPage(index, { behavior: "smooth" })}
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
- aria-label={`前往第 ${index + 1} 頁:${pageTitle}`}
132
- aria-current={active ? "page" : undefined}
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,28 +1,37 @@
1
1
  import {
2
+ useCallback,
3
+ useEffect,
2
4
  useMemo,
3
5
  useRef,
6
+ useState,
4
7
  type CSSProperties,
5
8
  type MouseEvent as ReactMouseEvent,
6
9
  type RefCallback,
7
10
  type RefObject,
8
11
  } from "react";
9
- import { BookOpen, ExternalLink, X } from "lucide-react";
12
+ import { ExternalLink, Ruler } from "lucide-react";
10
13
  import {
11
14
  collectBookmarkIndex,
12
15
  createAnchorPageMap,
13
16
  createPageObjectEntityId,
14
17
  getProjectIdentity,
18
+ getSourceBlockMap,
15
19
  resolveAnchorPageIndex,
16
20
  type DeploymentInfo,
17
21
  type HtmlPageBlock,
18
22
  type ReaderDocument,
19
23
  } from "../document-model";
20
24
  import type { InspectorState } from "../workbench/inspector";
25
+ import { groupSourceBlocksByPath } from "../workbench/inspector";
21
26
  import { useReaderRuntime } from "./useReaderRuntime";
22
27
  import { Bookmarks, CurrentPagePanel } from "./ReaderNavigationPanel";
23
28
  import type { DisplayPage } from "./readerTypes";
24
29
  import { usePageViewportScale } from "./usePageViewportScale";
25
30
  import type { PageLayoutMode } from "./pageViewportScaleModel";
31
+ import { PageZoomControl, SearchControl, type SearchControlSearcher } from "../workbench/actions";
32
+ import { WorkbenchShell } from "../workbench/shell";
33
+ import { formatPageGeometrySpec } from "../workbench/workbenchFormatters";
34
+ import { searchCorpus, type SearchCorpus } from "../shared";
26
35
 
27
36
  export const PUBLIC_DRAWER_BREAKPOINT = 1185;
28
37
  export type ViewMode = "paged";
@@ -41,29 +50,60 @@ export function PublicViewer({
41
50
  }) {
42
51
  const sourceContainerRef = useRef<HTMLDivElement | null>(null);
43
52
  const displayPages = pages;
44
- const viewModeState = useViewMode();
45
- const { viewMode } = viewModeState;
53
+ const { viewMode } = useViewMode();
46
54
  const bookmarks = collectBookmarkIndex(displayPages);
47
55
  const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
48
56
  const reader = useReaderRuntime({
49
57
  pageCount: displayPages.length,
58
+ leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
50
59
  rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
51
60
  });
52
- usePageViewportScale({
61
+ const [pageLayoutMode, setPageLayoutMode] = useState<PageLayoutMode>("single");
62
+ const pageViewport = usePageViewportScale({
53
63
  stageRef: reader.stageRef,
54
64
  pageContainerRef: sourceContainerRef,
55
65
  pageCount: displayPages.length,
56
- layoutMode: "single",
66
+ layoutMode: pageLayoutMode,
57
67
  });
58
68
  const currentPage = displayPages[reader.currentPageIndex];
59
69
  const staticPdfHref = deploymentInfo.pdf;
60
70
  const projectIdentity = getProjectIdentity(document.meta);
71
+ const pressType = document.meta.type === "slides" ? "slides" : "pages";
72
+ const pageGeometry = formatPageGeometrySpec(document.theme);
73
+ const sourceBlocksByPath = useMemo(
74
+ () => groupSourceBlocksByPath(getSourceBlockMap(document)),
75
+ [document],
76
+ );
61
77
 
62
- const drawerOpen = reader.rightPanelOpen;
78
+ // Static searcher: lazy-fetch /openpress/search-corpus.json on first
79
+ // query, cache for subsequent searches, then run the same literal-match
80
+ // logic the dev endpoint uses — no backend required for deployed pages.
81
+ const corpusRef = useRef<SearchCorpus | null>(null);
82
+ const corpusFetchRef = useRef<Promise<SearchCorpus> | null>(null);
83
+ const staticSearcher = useCallback<SearchControlSearcher>(async ({ query, scope, signal }) => {
84
+ if (!corpusRef.current) {
85
+ if (!corpusFetchRef.current) {
86
+ corpusFetchRef.current = fetch("/openpress/search-corpus.json", { cache: "force-cache" })
87
+ .then(async (response) => {
88
+ if (!response.ok) throw new Error(`Failed to load search corpus (${response.status})`);
89
+ return (await response.json()) as SearchCorpus;
90
+ })
91
+ .catch((error) => {
92
+ corpusFetchRef.current = null;
93
+ throw error;
94
+ });
95
+ }
96
+ const corpus = await corpusFetchRef.current;
97
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
98
+ corpusRef.current = corpus;
99
+ }
100
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
101
+ return searchCorpus(corpusRef.current, { query, scope, caseSensitive: false });
102
+ }, []);
63
103
 
64
104
  const selectPublicPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
65
105
  reader.setPage(pageIndex, options);
66
- if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && drawerOpen) reader.toggleRightPanel();
106
+ if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && reader.leftPanelOpen) reader.toggleLeftPanel();
67
107
  };
68
108
 
69
109
  const selectPublicAnchor = (anchorId: string, pageIndex?: number) => {
@@ -73,84 +113,112 @@ export function PublicViewer({
73
113
  return true;
74
114
  };
75
115
 
76
- const appClassName = [
77
- "reader-app openpress-reader-app openpress-public-viewer is-ready",
78
- drawerOpen ? "" : "is-closed-right",
79
- ].filter(Boolean).join(" ");
80
-
81
116
  const handleOpenStaticPdf = () => {
82
117
  if (!staticPdfHref) return;
83
118
  window.open(staticPdfHref, "_blank", "noopener,noreferrer");
84
119
  };
85
120
 
86
121
  return (
87
- <main className="openpress-workbench openpress-public-shell" style={style} data-openpress-public-viewer="true" aria-label={`${document.meta.title} 公開頁`}>
88
- <div
89
- className={appClassName}
90
- data-openpress-react-runtime="true"
91
- data-openpress-view-mode={viewMode}
92
- >
93
- {drawerOpen && (
94
- <div className="openpress-public-scrim" aria-hidden="true" onClick={reader.toggleRightPanel} />
95
- )}
96
- <button type="button" className="openpress-public-fab" aria-label="開啟目錄" onClick={reader.toggleRightPanel}>
97
- <BookOpen size={20} aria-hidden="true" />
98
- </button>
122
+ <WorkbenchShell
123
+ style={style}
124
+ viewMode={viewMode}
125
+ pressType={pressType}
126
+ inspectorMode={false}
127
+ leftPanelOpen={reader.leftPanelOpen}
128
+ rightPanelOpen={false}
129
+ onToggleLeftPanel={reader.toggleLeftPanel}
130
+ onToggleRightPanel={reader.toggleLeftPanel}
131
+ withRightPanel={false}
132
+ publicViewer
133
+ >
134
+ <WorkbenchShell.Toolbar>
135
+ <div className="openpress-workbench-toolbar__group" aria-label="輸出">
136
+ <button
137
+ type="button"
138
+ className="openpress-workbench-toolbar-action"
139
+ data-openpress-public-export
140
+ disabled={!staticPdfHref}
141
+ onClick={handleOpenStaticPdf}
142
+ title={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
143
+ aria-label={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
144
+ >
145
+ <ExternalLink aria-hidden="true" />
146
+ <span className="openpress-workbench-toolbar-action__label">
147
+ {staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
148
+ </span>
149
+ </button>
150
+ </div>
151
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
152
+ <button
153
+ type="button"
154
+ className="openpress-workbench-page-geometry"
155
+ data-openpress-page-geometry
156
+ title={pageGeometry.title}
157
+ aria-label={`頁面規格 ${pageGeometry.title}`}
158
+ >
159
+ <Ruler aria-hidden="true" />
160
+ <span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
161
+ <span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
162
+ </button>
163
+ <PageZoomControl
164
+ scaleMode={pageViewport.scaleMode}
165
+ scaleLabel={pageViewport.scaleLabel}
166
+ pageLayoutMode={pageLayoutMode}
167
+ onScaleModeChange={pageViewport.setScaleMode}
168
+ onPageLayoutModeChange={setPageLayoutMode}
169
+ />
170
+ <SearchControl
171
+ sourceBlocksByPath={sourceBlocksByPath}
172
+ onSelectPage={selectPublicPage}
173
+ searcher={staticSearcher}
174
+ />
175
+ </div>
176
+ </WorkbenchShell.Toolbar>
99
177
 
100
- <section className="openpress-workbench__stage openpress-public-viewer__stage" aria-label="公開文件頁面">
101
- <main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
102
- <PublicPage
103
- pages={displayPages}
104
- currentPageIndex={reader.currentPageIndex}
105
- devMode={false}
106
- sourceContainerRef={sourceContainerRef}
107
- registerPage={reader.registerPage}
108
- onInternalAnchorNavigate={selectPublicAnchor}
109
- />
110
- </main>
178
+ <WorkbenchShell.LeftPanel>
179
+ <section className="openpress-public-identity" aria-label="文件資訊">
180
+ <strong>
181
+ <span className="openpress-public-title-main">{projectIdentity.name}</span>
182
+ {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
183
+ </strong>
184
+ {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
111
185
  </section>
112
-
113
- <aside className="reader-side-nav openpress-workspace-panel openpress-public-navigation" aria-label="文件導覽">
114
- <button type="button" className="openpress-public-drawer-close" aria-label="關閉目錄" onClick={reader.toggleRightPanel}>
115
- <X size={16} aria-hidden="true" />
116
- </button>
117
- <section className="openpress-public-identity" aria-label="文件資訊">
118
- <strong>
119
- <span className="openpress-public-title-main">{projectIdentity.name}</span>
120
- {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
121
- </strong>
122
- {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
123
- </section>
124
- <div className="openpress-public-actions" aria-label="文件操作">
125
- <button
126
- type="button"
127
- className="openpress-public-export-button"
128
- data-openpress-public-export
129
- disabled={!staticPdfHref}
130
- onClick={handleOpenStaticPdf}
131
- >
132
- <ExternalLink aria-hidden="true" />
133
- {!staticPdfHref ? "PDF 未部署" : "開啟 PDF"}
134
- </button>
135
- </div>
136
- <section id="openpress-bookmarks" className="openpress-panel-section openpress-panel-section--bookmarks" aria-label="章節書籤">
186
+ {bookmarks.length > 0 ? (
187
+ <section
188
+ id="openpress-bookmarks"
189
+ className="openpress-panel-section openpress-panel-section--bookmarks"
190
+ aria-label="章節書籤"
191
+ >
137
192
  <nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
138
193
  <div className="reader-bookmarks-rail" aria-hidden="true" />
139
194
  <Bookmarks items={bookmarks} currentPageIndex={reader.currentPageIndex} onSelectPage={selectPublicPage} />
140
195
  </nav>
141
196
  </section>
142
- <CurrentPagePanel
143
- currentPageLabel={reader.currentPageLabel}
144
- totalPageLabel={reader.totalPageLabel}
145
- progressPercent={reader.progressPercent}
146
- title={currentPage?.title || document.meta.title}
147
- pageLabelPrefix="頁"
148
- showHeading={false}
149
- showTitle={false}
197
+ ) : null}
198
+ <CurrentPagePanel
199
+ currentPageLabel={reader.currentPageLabel}
200
+ totalPageLabel={reader.totalPageLabel}
201
+ progressPercent={reader.progressPercent}
202
+ title={currentPage?.title || document.meta.title}
203
+ pageLabelPrefix="頁"
204
+ showHeading={false}
205
+ showTitle={false}
206
+ />
207
+ </WorkbenchShell.LeftPanel>
208
+
209
+ <WorkbenchShell.MainContent>
210
+ <main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
211
+ <PublicPage
212
+ pages={displayPages}
213
+ currentPageIndex={reader.currentPageIndex}
214
+ sourceContainerRef={sourceContainerRef}
215
+ registerPage={reader.registerPage}
216
+ onInternalAnchorNavigate={selectPublicAnchor}
217
+ pageLayoutMode={pageLayoutMode}
150
218
  />
151
- </aside>
152
- </div>
153
- </main>
219
+ </main>
220
+ </WorkbenchShell.MainContent>
221
+ </WorkbenchShell>
154
222
  );
155
223
  }
156
224
 
@@ -171,6 +239,30 @@ export function PrintDocument({
171
239
  const displayPages = pages;
172
240
  const registerPage = () => () => undefined;
173
241
 
242
+ // Mirror the per-document page geometry vars onto :root so the @page
243
+ // rule in print-route.css can resolve them. CSS custom properties set
244
+ // on <main> never reach @page in any browser; without this, headless
245
+ // Chrome falls back to the workspace theme default (210mm × 297mm A4)
246
+ // and slide/social/landscape presses print onto the wrong paper.
247
+ useEffect(() => {
248
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
249
+ const root = window.document.documentElement;
250
+ const overrides: Array<[string, string]> = [];
251
+ for (const [key, value] of Object.entries(style)) {
252
+ if (typeof key === "string" && key.startsWith("--") && typeof value === "string") {
253
+ overrides.push([key, value]);
254
+ }
255
+ }
256
+ const previous = overrides.map(([key]) => [key, root.style.getPropertyValue(key)] as const);
257
+ overrides.forEach(([key, value]) => root.style.setProperty(key, value));
258
+ return () => {
259
+ previous.forEach(([key, value]) => {
260
+ if (value) root.style.setProperty(key, value);
261
+ else root.style.removeProperty(key);
262
+ });
263
+ };
264
+ }, [style]);
265
+
174
266
  return (
175
267
  <main
176
268
  className="openpress-print-document"
@@ -181,7 +273,6 @@ export function PrintDocument({
181
273
  <PublicPage
182
274
  pages={displayPages}
183
275
  currentPageIndex={0}
184
- devMode={false}
185
276
  sourceContainerRef={sourceContainerRef}
186
277
  registerPage={registerPage}
187
278
  exposeSourceData
@@ -193,7 +284,6 @@ export function PrintDocument({
193
284
  export function PublicPage({
194
285
  pages,
195
286
  currentPageIndex,
196
- devMode,
197
287
  sourceContainerRef,
198
288
  registerPage,
199
289
  exposeSourceData = false,
@@ -203,7 +293,6 @@ export function PublicPage({
203
293
  }: {
204
294
  pages: DisplayPage[];
205
295
  currentPageIndex: number;
206
- devMode: boolean;
207
296
  sourceContainerRef: RefObject<HTMLDivElement | null>;
208
297
  registerPage: (pageIndex: number) => RefCallback<HTMLElement>;
209
298
  exposeSourceData?: boolean;
@@ -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,11 @@ export function SlidePresentationPage({
66
70
  const handleKeyDown = (event: KeyboardEvent) => {
67
71
  if (isEditableTarget(event.target)) return;
68
72
  if (event.key === "Escape") {
69
- event.preventDefault();
70
73
  const activeDocument = globalThis.document;
71
74
  if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
75
+ event.preventDefault();
72
76
  void activeDocument.exitFullscreen();
73
- return;
74
77
  }
75
- onExitPresentation?.(currentPageIndexRef.current);
76
78
  return;
77
79
  }
78
80
  if (event.key === " " || event.code === "Space") {
@@ -178,6 +180,11 @@ export function SlidePresentationPage({
178
180
  </section>
179
181
 
180
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}
181
188
  <span
182
189
  className="openpress-slide-presenter__progress"
183
190
  data-openpress-present-progress
@@ -195,16 +202,31 @@ export function SlidePresentationPage({
195
202
  >
196
203
  <Maximize2 aria-hidden="true" />
197
204
  </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>
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}
208
230
  </div>
209
231
  </main>
210
232
  );