@open-press/core 1.2.0 → 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 (40) hide show
  1. package/engine/cli.mjs +1 -1
  2. package/engine/commands/_shared.mjs +10 -5
  3. package/engine/commands/deploy.mjs +19 -4
  4. package/engine/output/static-server.mjs +16 -9
  5. package/package.json +1 -1
  6. package/src/openpress/app/OpenPressApp.tsx +4 -1
  7. package/src/openpress/app/OpenPressRuntime.tsx +26 -1
  8. package/src/openpress/reader/PageThumbnailsPanel.tsx +28 -5
  9. package/src/openpress/reader/SlidePresentationPage.tsx +36 -19
  10. package/src/openpress/reader/SlidePublicPage.tsx +332 -0
  11. package/src/openpress/reader/index.ts +1 -0
  12. package/src/openpress/reader/pageViewportScaleModel.ts +5 -3
  13. package/src/openpress/reader/usePageViewportScale.ts +9 -5
  14. package/src/openpress/workbench/Workbench.tsx +46 -164
  15. package/src/openpress/workbench/actions/DeploymentControl.tsx +1 -1
  16. package/src/openpress/workbench/actions/ExportControl.tsx +267 -0
  17. package/src/openpress/workbench/actions/index.ts +1 -1
  18. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +7 -2
  19. package/src/openpress/workbench/hooks/useWorkbenchNavigation.ts +42 -0
  20. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +2 -278
  21. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +206 -0
  22. package/src/styles/openpress/app-shell.css +0 -83
  23. package/src/styles/openpress/print-route.css +1 -3
  24. package/src/styles/openpress/project-preview-panel.css +5 -783
  25. package/src/styles/openpress/public-viewer.css +7 -249
  26. package/src/styles/openpress/reader-runtime.css +0 -274
  27. package/src/styles/openpress/slide-presenter.css +150 -0
  28. package/src/styles/openpress/slide-public-viewer.css +222 -0
  29. package/src/styles/openpress/workbench-dialog.css +267 -0
  30. package/src/styles/openpress/workbench-export.css +154 -0
  31. package/src/styles/openpress/workbench-inline-editor.css +128 -0
  32. package/src/styles/openpress/workbench-panels.css +0 -88
  33. package/src/styles/openpress/workbench-search.css +257 -0
  34. package/src/styles/openpress/workbench-toolbar.css +422 -0
  35. package/src/styles/openpress/workbench.css +34 -1263
  36. package/src/styles/openpress/workspace-gallery.css +0 -5
  37. package/src/styles/openpress.css +7 -1
  38. package/vite.config.ts +16 -9
  39. package/src/openpress/workbench/actions/ExportImageControl.tsx +0 -96
  40. package/src/styles/openpress/media-workspace.css +0 -230
package/engine/cli.mjs CHANGED
@@ -87,7 +87,7 @@ Commands:
87
87
  typecheck
88
88
  image [--output <outputDir>] [--press <slug>] [--pages <selector>] [--no-build] [--dry-run]
89
89
  pdf [--output <outputDir>/<pdf.filename>] [--press <slug>] [--no-build] [--dry-run]
90
- deploy --confirm [--dry-run]
90
+ deploy --confirm [--press <slug>] [--dry-run]
91
91
  doctor [--json] [--no-cache] # version + skill staleness check
92
92
  upgrade [--dry-run] [--no-deps] [--no-skills] [--json] # apply updates; agent-driven
93
93
  migrate [--dry-run] [--no-deps] [--no-skills] [--json] # alias for upgrade; reads migration notes
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { captureUrlPagesToPng, printUrlToPdf, stopChildProcess, waitForPrintReady } from "../output/chrome-pdf.mjs";
7
- import { loadConfig, publicPdfHref } from "../runtime/config.mjs";
7
+ import { loadConfig } from "../runtime/config.mjs";
8
8
  import { exportDocument } from "../document-export.mjs";
9
9
  import { optimizePdfMediaForStaticRoot } from "../output/pdf-media.mjs";
10
10
 
@@ -142,7 +142,7 @@ function pressPrintUrl(host, port, slug) {
142
142
  return `http://${host}:${port}/${normalized}?print=1`;
143
143
  }
144
144
 
145
- function pressSuffixedFilename(baseFilename, slug) {
145
+ export function pressSuffixedFilename(baseFilename, slug) {
146
146
  const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
147
147
  if (!normalized) return baseFilename;
148
148
  const ext = path.extname(baseFilename);
@@ -150,6 +150,10 @@ function pressSuffixedFilename(baseFilename, slug) {
150
150
  return `${stem}-${normalized}${ext}`;
151
151
  }
152
152
 
153
+ export function publicPdfHrefForFilename(filename) {
154
+ return `/${filename}`;
155
+ }
156
+
153
157
  export async function buildReactPdf({
154
158
  root,
155
159
  config,
@@ -294,18 +298,19 @@ export function startStaticServer(root, config, host, port) {
294
298
  });
295
299
  }
296
300
 
297
- export async function writePdfStageDeployConfig(root, source, config) {
301
+ export async function writePdfStageDeployConfig(root, source, config, { pdfFilename = config.pdf.filename } = {}) {
298
302
  const deployRoot = path.resolve(root, source);
299
303
  const openpressDir = path.join(deployRoot, "openpress");
304
+ const pdfHref = publicPdfHrefForFilename(pdfFilename);
300
305
  await fs.mkdir(openpressDir, { recursive: true });
301
306
  await fs.writeFile(
302
307
  path.join(openpressDir, "deploy.json"),
303
- `${JSON.stringify({ pdf: publicPdfHref(config), deployed_at: new Date().toISOString() }, null, 2)}\n`,
308
+ `${JSON.stringify({ pdf: pdfHref, deployed_at: new Date().toISOString() }, null, 2)}\n`,
304
309
  "utf8",
305
310
  );
306
311
  await fs.writeFile(
307
312
  path.join(deployRoot, "_headers"),
308
- `${publicPdfHref(config)}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${config.pdf.filename}"\n`,
313
+ `${pdfHref}\n Content-Type: application/pdf\n Content-Disposition: inline; filename="${pdfFilename}"\n`,
309
314
  "utf8",
310
315
  );
311
316
  }
@@ -1,6 +1,12 @@
1
1
  import path from "node:path";
2
2
  import { deploySync } from "../output/deploy-sync.mjs";
3
- import { buildReactPdf, formatOpenPressCommand, runCommand, writePdfStageDeployConfig } from "./_shared.mjs";
3
+ import {
4
+ buildReactPdf,
5
+ formatOpenPressCommand,
6
+ pressSuffixedFilename,
7
+ runCommand,
8
+ writePdfStageDeployConfig,
9
+ } from "./_shared.mjs";
4
10
 
5
11
  export async function run({ root, config, options, recurse }) {
6
12
  if (config.deploy.requiresConfirmation === true && !options.confirm) {
@@ -10,11 +16,15 @@ export async function run({ root, config, options, recurse }) {
10
16
  const source = config.deploy.source;
11
17
  const projectName = config.deploy.projectName;
12
18
  const commitDirty = config.deploy.commitDirty;
19
+ const pressSlug = normalizePressSlug(options.press);
20
+ const pdfFilename = pressSuffixedFilename(config.pdf.filename, pressSlug);
21
+ const pdfArgs = ["pdf", ".", "--output", `${source}/${pdfFilename}`];
22
+ if (pressSlug) pdfArgs.push("--press", pressSlug);
13
23
  if (options.dryRun) {
14
24
  console.log("OpenPress deploy dry run");
15
25
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
16
26
  console.log(`Step: deploy-sync (copy ${config.outputDir} → ${source})`);
17
- console.log(`Command: ${formatOpenPressCommand(["pdf", ".", "--output", `${source}/${config.pdf.filename}`])}`);
27
+ console.log(`Command: ${formatOpenPressCommand(pdfArgs)}`);
18
28
  console.log(`Step: write ${source}/openpress/deploy.json with deployment metadata`);
19
29
  console.log(`Command: npx wrangler pages deploy ${source}${projectName ? ` --project-name=${projectName}` : ""}${commitDirty ? " --commit-dirty=true" : ""}`);
20
30
  return 0;
@@ -22,10 +32,15 @@ export async function run({ root, config, options, recurse }) {
22
32
  const renderCode = await recurse("render", [root, "--renderer", "react"]);
23
33
  if (renderCode !== 0) return renderCode;
24
34
  await deploySync(root, config.outputDir, source);
25
- await buildReactPdf({ root, config, outPath: path.resolve(root, source, config.pdf.filename), noBuild: true, recurse });
26
- await writePdfStageDeployConfig(root, source, config);
35
+ await buildReactPdf({ root, config, outPath: path.resolve(root, source, pdfFilename), noBuild: true, recurse, pressSlug });
36
+ await writePdfStageDeployConfig(root, source, config, { pdfFilename });
27
37
  const wranglerArgs = ["wrangler", "pages", "deploy", source];
28
38
  if (projectName) wranglerArgs.push(`--project-name=${projectName}`);
29
39
  if (commitDirty) wranglerArgs.push("--commit-dirty=true");
30
40
  return runCommand("npx", wranglerArgs, root);
31
41
  }
42
+
43
+ function normalizePressSlug(value) {
44
+ if (typeof value !== "string") return "";
45
+ return value.trim().replace(/^\/+|\/+$/g, "");
46
+ }
@@ -283,6 +283,11 @@ async function handleDeployRequest(req, res) {
283
283
  return;
284
284
  }
285
285
 
286
+ const body = await readJsonBody(req);
287
+ const slug = normalizePressSlug(body?.press);
288
+ const command = slug ? `open-press deploy . --confirm --press ${slug}` : "open-press deploy . --confirm";
289
+ const pdfFilename = pressFilename(config.pdf.filename, slug);
290
+
286
291
  if (!isDeployConfigured()) {
287
292
  writeJson(res, 400, {
288
293
  ok: false,
@@ -292,15 +297,15 @@ async function handleDeployRequest(req, res) {
292
297
  deploy_adapter: config.deploy.adapter,
293
298
  deploy_source: config.deploy.source,
294
299
  deploy_project_name: config.deploy.projectName,
295
- command: "open-press deploy . --confirm",
300
+ command,
296
301
  });
297
302
  return;
298
303
  }
299
304
 
300
- const result = await runDeploy();
305
+ const result = await runDeploy(slug);
301
306
  const deployedUrl = extractDeployUrl(result.stdout);
302
307
  if (result.code === 0 && deployedUrl) {
303
- await writeDeploymentPublicUrl(deployedUrl);
308
+ await writeDeploymentPublicUrl(deployedUrl, pdfFilename);
304
309
  }
305
310
  const deploymentInfo = await readDeploymentInfo();
306
311
  const publicUrl = deployedUrl ?? deploymentInfo.public_url;
@@ -308,10 +313,10 @@ async function handleDeployRequest(req, res) {
308
313
  ok: result.code === 0,
309
314
  code: result.code,
310
315
  deployed_at: deploymentInfo.deployed_at,
311
- pdf: deployedUrl ? `${deployedUrl}/${config.pdf.filename}` : deploymentInfo.pdf,
316
+ pdf: deployedUrl ? `${deployedUrl}/${pdfFilename}` : deploymentInfo.pdf,
312
317
  public_url: publicUrl,
313
318
  dirty: false,
314
- command: "open-press deploy . --confirm",
319
+ command,
315
320
  stdout: result.stdout,
316
321
  stderr: result.stderr,
317
322
  });
@@ -419,9 +424,11 @@ function runLocalPdfExport(slug = "") {
419
424
  });
420
425
  }
421
426
 
422
- function runDeploy() {
427
+ function runDeploy(slug = "") {
428
+ const cliArgs = [CLI_ENTRY, "deploy", ".", "--confirm"];
429
+ if (slug) cliArgs.push("--press", slug);
423
430
  return new Promise((resolve) => {
424
- const child = spawn("node", [CLI_ENTRY, "deploy", ".", "--confirm"], {
431
+ const child = spawn("node", cliArgs, {
425
432
  cwd: workspace,
426
433
  shell: false,
427
434
  });
@@ -485,7 +492,7 @@ async function readDeploymentInfo() {
485
492
  }
486
493
  }
487
494
 
488
- async function writeDeploymentPublicUrl(publicUrl) {
495
+ async function writeDeploymentPublicUrl(publicUrl, pdfFilename = config.pdf.filename) {
489
496
  let deployConfig = {};
490
497
  try {
491
498
  deployConfig = JSON.parse(await fs.readFile(config.paths.deployMetadata, "utf8"));
@@ -495,7 +502,7 @@ async function writeDeploymentPublicUrl(publicUrl) {
495
502
  await fs.mkdir(path.dirname(config.paths.deployMetadata), { recursive: true });
496
503
  await fs.writeFile(
497
504
  config.paths.deployMetadata,
498
- `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${config.pdf.filename}`, public_url: publicUrl }, null, 2)}\n`,
505
+ `${JSON.stringify({ ...deployConfig, pdf: `${publicUrl}/${pdfFilename}`, public_url: publicUrl }, null, 2)}\n`,
499
506
  "utf8",
500
507
  );
501
508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",
@@ -230,7 +230,10 @@ export function OpenPressApp() {
230
230
  const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
231
231
  const openPresentation = state.document.meta.type === "slides" && presentationSlug
232
232
  ? (pageIndex: number) => {
233
- openPressRoute(presentationSlug, "present", pageIndex, { fullscreen: true });
233
+ const slug = normalizeSlug(presentationSlug);
234
+ const pathname = slug ? `/${slug}` : "/";
235
+ const hash = `#page-${String(pageIndex + 1).padStart(2, "0")}`;
236
+ window.open(`${pathname}?fullscreen=1${hash}`, "_blank", "noopener,noreferrer");
234
237
  }
235
238
  : undefined;
236
239
 
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState, type CSSProperties } from "react";
2
- import { PrintDocument, PublicViewer, SlidePresentationPage } from "../reader";
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 {
@@ -82,6 +82,20 @@ export function OpenPressRuntime({
82
82
  );
83
83
  }
84
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
+
85
99
  if (!workspaceMode) {
86
100
  return <PublicViewer document={document} pages={htmlPages} style={style} deploymentInfo={deploymentInfo} />;
87
101
  }
@@ -164,6 +178,17 @@ function useLocationVersion() {
164
178
  return version;
165
179
  }
166
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
+
167
192
  function themeToCssVariables(theme?: Theme) {
168
193
  const style: CSSProperties & Record<`--${string}`, string> = {
169
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,7 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
2
- import { Maximize2, X } from "lucide-react";
2
+ import { Download, Maximize2, X } from "lucide-react";
3
3
  import { createPageObjectEntityId } from "../document-model";
4
- import type { HtmlPageBlock, ReaderDocument } from "../document-model";
4
+ import type { DeploymentInfo, HtmlPageBlock, ReaderDocument } from "../document-model";
5
5
  import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
6
6
  import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel";
7
7
  import { usePageViewportScale } from "./usePageViewportScale";
@@ -13,11 +13,13 @@ export function SlidePresentationPage({
13
13
  pages,
14
14
  style,
15
15
  onExitPresentation,
16
+ deploymentInfo,
16
17
  }: {
17
18
  document: ReaderDocument;
18
19
  pages: HtmlPageBlock[];
19
20
  style: CSSProperties;
20
21
  onExitPresentation?: (pageIndex: number) => void;
22
+ deploymentInfo?: DeploymentInfo;
21
23
  }) {
22
24
  const sourceContainerRef = useRef<HTMLDivElement | null>(null);
23
25
  const stageRef = useRef<HTMLElement | null>(null);
@@ -35,6 +37,8 @@ export function SlidePresentationPage({
35
37
  pageContainerRef: sourceContainerRef,
36
38
  pageCount: pages.length,
37
39
  layoutMode: "single",
40
+ initialScaleMode: "fit-page",
41
+ maxFitScale: Infinity,
38
42
  });
39
43
  const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)];
40
44
  const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1);
@@ -66,13 +70,6 @@ export function SlidePresentationPage({
66
70
  const handleKeyDown = (event: KeyboardEvent) => {
67
71
  if (isEditableTarget(event.target)) return;
68
72
  if (event.key === "Escape") {
69
- // Esc is reserved for exiting browser fullscreen. The chrome HUD
70
- // already exposes explicit "re-enter fullscreen" and "close"
71
- // buttons; navigating out of the presenter from a stray keystroke
72
- // would yank the user back to the workspace shell unexpectedly
73
- // (and racily, since the same Esc that triggered the browser's
74
- // fullscreen exit is also delivered to this handler with
75
- // fullscreenElement already null).
76
73
  const activeDocument = globalThis.document;
77
74
  if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
78
75
  event.preventDefault();
@@ -183,6 +180,11 @@ export function SlidePresentationPage({
183
180
  </section>
184
181
 
185
182
  <div className="openpress-slide-presenter__hud" aria-label="放映控制">
183
+ {deploymentInfo && document.meta.title ? (
184
+ <span className="openpress-slide-presenter__title" aria-label="簡報標題">
185
+ {document.meta.title}
186
+ </span>
187
+ ) : null}
186
188
  <span
187
189
  className="openpress-slide-presenter__progress"
188
190
  data-openpress-present-progress
@@ -200,16 +202,31 @@ export function SlidePresentationPage({
200
202
  >
201
203
  <Maximize2 aria-hidden="true" />
202
204
  </button>
203
- <button
204
- type="button"
205
- className="openpress-slide-presenter__button"
206
- data-openpress-present-exit
207
- onClick={() => onExitPresentation?.(currentPageIndex)}
208
- aria-label="回到工作台"
209
- title="回到工作台"
210
- >
211
- <X aria-hidden="true" />
212
- </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}
213
230
  </div>
214
231
  </main>
215
232
  );