@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.
@@ -139,9 +139,51 @@ export async function inspectRenderedOverflow({ root, config, host = "127.0.0.1"
139
139
  }
140
140
  }
141
141
 
142
- export async function waitForInspectionReady(client) {
143
- const deadline = Date.now() + 30000;
144
- while (Date.now() < deadline) {
142
+ export const INSPECTION_READY_DEFAULTS = Object.freeze({
143
+ totalTimeoutMs: 300_000,
144
+ idleTimeoutMs: 30_000,
145
+ pollIntervalMs: 100,
146
+ });
147
+
148
+ export function resolveInspectionReadyTiming(env = process.env) {
149
+ const total = Number(env.OPENPRESS_INSPECTION_TIMEOUT_MS);
150
+ const idle = Number(env.OPENPRESS_INSPECTION_IDLE_MS);
151
+ return {
152
+ totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : INSPECTION_READY_DEFAULTS.totalTimeoutMs,
153
+ idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : INSPECTION_READY_DEFAULTS.idleTimeoutMs,
154
+ pollIntervalMs: INSPECTION_READY_DEFAULTS.pollIntervalMs,
155
+ };
156
+ }
157
+
158
+ function formatInspectionTimeoutMessage(reason, snapshot, timing, elapsedMs) {
159
+ const seconds = Math.round(elapsedMs / 1000);
160
+ const observed = `(observed ${snapshot.pageCount} page(s))`;
161
+ if (reason === "idle") {
162
+ return (
163
+ `Timed out waiting for OpenPress pagination before inspection. ` +
164
+ `No progress for ${seconds}s ${observed}. ` +
165
+ `Raise OPENPRESS_INSPECTION_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
166
+ );
167
+ }
168
+ return (
169
+ `Timed out waiting for OpenPress pagination before inspection. ` +
170
+ `Total ${seconds}s exceeded ${observed}. ` +
171
+ `Raise OPENPRESS_INSPECTION_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
172
+ );
173
+ }
174
+
175
+ export async function waitForInspectionReady(client, timing = resolveInspectionReadyTiming()) {
176
+ const startedAt = Date.now();
177
+ let lastSignature = "";
178
+ let lastProgressAt = startedAt;
179
+ let lastSnapshot = { pageCount: 0 };
180
+
181
+ while (true) {
182
+ const totalElapsed = Date.now() - startedAt;
183
+ if (totalElapsed > timing.totalTimeoutMs) {
184
+ throw new Error(formatInspectionTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
185
+ }
186
+
145
187
  const result = await client.send("Runtime.evaluate", {
146
188
  returnByValue: true,
147
189
  awaitPromise: true,
@@ -149,9 +191,23 @@ export async function waitForInspectionReady(client) {
149
191
  });
150
192
  const value = result.result?.value;
151
193
  if (Array.isArray(value)) return value;
152
- await delay(100);
194
+
195
+ const pageCount = Number.isFinite(Number(value?.pageCount)) ? Number(value.pageCount) : lastSnapshot.pageCount;
196
+ lastSnapshot = { pageCount };
197
+
198
+ const signature = String(pageCount);
199
+ if (signature !== lastSignature) {
200
+ lastSignature = signature;
201
+ lastProgressAt = Date.now();
202
+ }
203
+
204
+ const idleElapsed = Date.now() - lastProgressAt;
205
+ if (idleElapsed > timing.idleTimeoutMs) {
206
+ throw new Error(formatInspectionTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
207
+ }
208
+
209
+ await delay(timing.pollIntervalMs);
153
210
  }
154
- throw new Error("Timed out waiting for OpenPress pagination before inspection.");
155
211
  }
156
212
 
157
213
  export function overflowIssuesFromMeasurements(measurements) {
@@ -241,7 +297,9 @@ function humanOverflowTarget(code) {
241
297
  function inspectionExpression() {
242
298
  return `Promise.resolve().then(async () => {
243
299
  const root = document.querySelector('[data-openpress-print-document="true"]');
244
- if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return null;
300
+ if (!root) return { pending: true, pageCount: 0 };
301
+ const candidates = root.querySelectorAll('.openpress-html-page');
302
+ if (candidates.length === 0) return { pending: true, pageCount: 0 };
245
303
 
246
304
  await document.fonts?.ready;
247
305
  await Promise.all(Array.from(document.images).map(async (img) => {
@@ -290,7 +348,7 @@ function inspectionExpression() {
290
348
  };
291
349
 
292
350
  const wrappers = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
293
- if (wrappers.length === 0) return null;
351
+ if (wrappers.length === 0) return { pending: true, pageCount: candidates.length };
294
352
  return wrappers.map((wrapper, index) => {
295
353
  const page = wrapper.querySelector('.reader-page') || wrapper;
296
354
  const frame = page.querySelector('.page-frame') || page;
@@ -0,0 +1,87 @@
1
+ const TOKEN_PATTERN = /^\s*(-?\d*)\s*(?:-\s*(\d*))?\s*$/;
2
+
3
+ export function parsePageSelector(input) {
4
+ if (typeof input !== "string") {
5
+ throw new TypeError("Page selector must be a string");
6
+ }
7
+ const trimmed = input.trim();
8
+ if (trimmed === "") {
9
+ throw new Error("Page selector is empty; expected something like '3', '3,5-7', or '12-'.");
10
+ }
11
+
12
+ const segments = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
13
+ if (segments.length === 0) {
14
+ throw new Error(`Page selector "${input}" has no usable segments.`);
15
+ }
16
+
17
+ return segments.map((segment) => parseSegment(segment, input));
18
+ }
19
+
20
+ function parseSegment(segment, original) {
21
+ if (!segment.includes("-")) {
22
+ const value = toPositiveInteger(segment, original);
23
+ return { kind: "single", value };
24
+ }
25
+
26
+ if (segment === "-") {
27
+ throw new Error(`Page selector "${original}" contains a bare "-"; ranges need at least one bound.`);
28
+ }
29
+
30
+ const dashIndex = segment.indexOf("-");
31
+ if (segment.indexOf("-", dashIndex + 1) !== -1) {
32
+ throw new Error(`Page selector segment "${segment}" has too many dashes.`);
33
+ }
34
+
35
+ const leftRaw = segment.slice(0, dashIndex);
36
+ const rightRaw = segment.slice(dashIndex + 1);
37
+ const from = leftRaw.trim() === "" ? null : toPositiveInteger(leftRaw, original);
38
+ const to = rightRaw.trim() === "" ? null : toPositiveInteger(rightRaw, original);
39
+
40
+ if (from != null && to != null && from > to) {
41
+ throw new Error(`Page selector range "${segment}" goes backwards (${from} > ${to}).`);
42
+ }
43
+
44
+ return { kind: "range", from, to };
45
+ }
46
+
47
+ function toPositiveInteger(raw, original) {
48
+ const trimmed = raw.trim();
49
+ if (!/^\d+$/.test(trimmed)) {
50
+ throw new Error(`Page selector "${original}" contains non-integer token "${raw}".`);
51
+ }
52
+ const value = Number(trimmed);
53
+ if (!Number.isInteger(value) || value < 1) {
54
+ throw new Error(`Page selector "${original}" contains out-of-range page number "${raw}"; pages start at 1.`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function resolvePageSelector(spec, totalPages) {
60
+ if (!Array.isArray(spec)) {
61
+ throw new TypeError("resolvePageSelector expects a parsed selector array");
62
+ }
63
+ if (!Number.isInteger(totalPages) || totalPages < 0) {
64
+ throw new TypeError("resolvePageSelector expects a non-negative integer totalPages");
65
+ }
66
+ if (totalPages === 0) return [];
67
+
68
+ const selected = new Set();
69
+ for (const segment of spec) {
70
+ if (segment.kind === "single") {
71
+ if (segment.value > totalPages) {
72
+ throw new Error(`Page ${segment.value} is out of range; document has ${totalPages} page(s).`);
73
+ }
74
+ selected.add(segment.value);
75
+ continue;
76
+ }
77
+ const from = segment.from ?? 1;
78
+ const to = segment.to ?? totalPages;
79
+ if (from > totalPages) {
80
+ throw new Error(`Range start ${from} is out of range; document has ${totalPages} page(s).`);
81
+ }
82
+ const upper = Math.min(to, totalPages);
83
+ for (let i = from; i <= upper; i += 1) selected.add(i);
84
+ }
85
+
86
+ return Array.from(selected).sort((a, b) => a - b);
87
+ }
@@ -93,8 +93,7 @@ export async function applySourceBlockTextEdit({
93
93
  }) {
94
94
  const requestedPath = stringValue(sourcePath);
95
95
  if (!requestedPath) throw new Error("Source edit requires a source path.");
96
- const files = await collectSourceTextFiles(config, { scope: "content" });
97
- const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
96
+ const file = await findEditableSourceTextFile(config, requestedPath);
98
97
  if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
99
98
 
100
99
  const result = sourceMode
@@ -120,8 +119,7 @@ export async function applySourceBlockTextEdit({
120
119
  export async function readSourceBlockText({ config, path: sourcePath, source }) {
121
120
  const requestedPath = stringValue(sourcePath);
122
121
  if (!requestedPath) throw new Error("Source read requires a source path.");
123
- const files = await collectSourceTextFiles(config, { scope: "content" });
124
- const file = files.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
122
+ const file = await findEditableSourceTextFile(config, requestedPath);
125
123
  if (!file) throw new Error(`Editable source file not found: ${requestedPath}`);
126
124
  return {
127
125
  path: file.relativePath,
@@ -205,6 +203,13 @@ export function applySourceBlockTextEditToText(documentText, {
205
203
  name,
206
204
  });
207
205
  }
206
+ if (kind === "object-text") {
207
+ return applySourceBlockObjectTextEditToText(documentText, {
208
+ source,
209
+ text,
210
+ blockId,
211
+ });
212
+ }
208
213
  if (kind === "element" && name === "pre") {
209
214
  return applySourceBlockCodeEditToText(documentText, {
210
215
  source,
@@ -265,6 +270,48 @@ export function applySourceBlockTextEditToText(documentText, {
265
270
  };
266
271
  }
267
272
 
273
+ function applySourceBlockObjectTextEditToText(documentText, {
274
+ source,
275
+ text,
276
+ blockId,
277
+ } = {}) {
278
+ const sourceRange = normalizeSourceRange(source);
279
+ const replacementText = normalizeEditedText(text);
280
+ const lines = splitTextLines(documentText);
281
+ const startIndex = sourceRange.line - 1;
282
+ const endIndex = sourceRange.endLine - 1;
283
+ if (!lines[startIndex]) throw new Error(`Source edit line ${sourceRange.line} is outside the source file.`);
284
+ if (!lines[endIndex]) throw new Error(`Source edit end line ${sourceRange.endLine} is outside the source file.`);
285
+
286
+ const selectedLines = lines.slice(startIndex, endIndex + 1);
287
+ const before = selectedLines.map((line) => line.line).join("\n");
288
+ const after = replaceSourceRangeText(before, sourceRange, replacementText);
289
+ const replacementLines = after.split("\n");
290
+ const ending = selectedLines[selectedLines.length - 1].ending;
291
+ const nextLines = [
292
+ ...lines.slice(0, startIndex),
293
+ ...replacementLines.map((line, index) => ({
294
+ line,
295
+ ending: index === replacementLines.length - 1 ? ending : "\n",
296
+ })),
297
+ ...lines.slice(endIndex + 1),
298
+ ];
299
+
300
+ return {
301
+ text: joinTextLines(nextLines),
302
+ edit: {
303
+ blockId,
304
+ line: sourceRange.line,
305
+ column: sourceRange.column,
306
+ endLine: sourceRange.endLine,
307
+ endColumn: sourceRange.endColumn,
308
+ before,
309
+ after,
310
+ text: replacementText,
311
+ },
312
+ };
313
+ }
314
+
268
315
  function applySourceBlockCodeEditToText(documentText, {
269
316
  source,
270
317
  text,
@@ -722,6 +769,15 @@ function replaceTableCellContent(cell, replacementText) {
722
769
  return `${leading}${replacementText}${trailing}`;
723
770
  }
724
771
 
772
+ function replaceSourceRangeText(sourceText, sourceRange, replacementText) {
773
+ const lines = sourceText.split("\n");
774
+ const firstLine = lines[0] ?? "";
775
+ const lastLine = lines[lines.length - 1] ?? "";
776
+ const prefix = firstLine.slice(0, Math.max(0, sourceRange.column - 1));
777
+ const suffix = lastLine.slice(Math.max(0, sourceRange.endColumn - 1));
778
+ return `${prefix}${replacementText}${suffix}`;
779
+ }
780
+
725
781
  function markdownTextPrefix(line, { kind, name }) {
726
782
  if (kind === "list-item") return line.match(/^(\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)/)?.[1] ?? "- ";
727
783
  if (name && /^h[1-6]$/.test(name)) return line.match(/^(\s{0,3}#{1,6}\s+)/)?.[1] ?? `${"#".repeat(Number(name.slice(1)))} `;
@@ -791,8 +847,8 @@ function applySourceBlockComponentCaptionEditToText(documentText, {
791
847
  }
792
848
 
793
849
  function assertEditableComponentCaption({ name, blockId }) {
794
- if (name === "MediaFigure" || name === "ImageFigure") return;
795
- throw new Error(`Only MediaFigure and ImageFigure caption props can be edited inline${blockId ? `: ${blockId}` : ""}.`);
850
+ if (typeof name === "string" && /^[A-Z][A-Za-z0-9_$]*$/.test(name)) return;
851
+ throw new Error(`Component caption edits require a named React component${blockId ? `: ${blockId}` : ""}.`);
796
852
  }
797
853
 
798
854
  function replaceComponentCaptionProp(sourceText, replacementText) {
@@ -820,6 +876,15 @@ function sourceTextPathMatches(candidatePath, requestedPath) {
820
876
  return normalizeSourceTextPath(candidatePath) === normalizeSourceTextPath(requestedPath);
821
877
  }
822
878
 
879
+ async function findEditableSourceTextFile(config, requestedPath) {
880
+ const contentFiles = await collectSourceTextFiles(config, { scope: "content" });
881
+ const contentMatch = contentFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
882
+ if (contentMatch) return contentMatch;
883
+
884
+ const allFiles = await collectSourceTextFiles(config, { scope: "all" });
885
+ return allFiles.find((candidate) => sourceTextPathMatches(candidate.relativePath, requestedPath));
886
+ }
887
+
823
888
  function normalizeSourceTextPath(value) {
824
889
  return String(value ?? "")
825
890
  .replaceAll("\\", "/")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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",
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { OpenPressRuntime } from "./OpenPressRuntime";
2
+ import { OpenPressRuntime, type OpenPressRuntimeMode } from "./OpenPressRuntime";
3
3
  import { WorkspaceGalleryPage } from "./WorkspaceGalleryPage";
4
4
  import { isLocalWorkspaceHost } from "../shared";
5
5
  import type {
@@ -28,9 +28,15 @@ type LoadState =
28
28
  // or for the root entry of a multi-Press workspace. Otherwise the
29
29
  // active press's slug — used by refresh/back/forward to re-resolve.
30
30
  activeSlug: string;
31
+ runtimeMode: OpenPressRuntimeMode;
31
32
  }
32
33
  | { status: "error"; message: string };
33
34
 
35
+ interface WorkspaceRoute {
36
+ slug: string;
37
+ mode: OpenPressRuntimeMode;
38
+ }
39
+
34
40
  interface DeployConfig {
35
41
  pdf?: string;
36
42
  deployed_at?: string;
@@ -63,23 +69,30 @@ export function OpenPressApp() {
63
69
 
64
70
  // Single resolution function — same code path for "boot from URL",
65
71
  // "click gallery card", and "browser back button". Given a manifest
66
- // + slug, decides whether to render gallery or load a press.
67
- const resolveFromSlug = useCallback(async (
72
+ // + route, decides whether to render gallery or load a press.
73
+ const resolveFromRoute = useCallback(async (
68
74
  manifest: WorkspaceManifest | null,
69
- slug: string,
75
+ route: WorkspaceRoute,
70
76
  deploymentInfo: DeploymentInfo,
71
77
  ) => {
72
78
  // No manifest (legacy deploy): always load /openpress/document.json.
73
79
  if (!manifest || manifest.presses.length === 0) {
74
80
  const document = await loadReaderDocument("/openpress/document.json");
75
- setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: "" });
81
+ setState({
82
+ status: "ready",
83
+ document,
84
+ deploymentInfo,
85
+ manifest,
86
+ activeSlug: "",
87
+ runtimeMode: resolveRuntimeMode(document, route.mode),
88
+ });
76
89
  return;
77
90
  }
78
91
 
79
92
  // Empty slug + multi-Press: show gallery. Empty slug + single-Press:
80
93
  // load the only press. Same expression handles both — array length
81
94
  // is the only thing that matters.
82
- const normalizedSlug = normalizeSlug(slug);
95
+ const normalizedSlug = normalizeSlug(route.slug);
83
96
  if (!normalizedSlug && manifestHasMultiplePresses(manifest)) {
84
97
  setState({ status: "gallery", manifest, deploymentInfo });
85
98
  return;
@@ -96,7 +109,14 @@ export function OpenPressApp() {
96
109
  return;
97
110
  }
98
111
  const document = await loadReaderDocument(press.documentUrl);
99
- setState({ status: "ready", document, deploymentInfo, manifest, activeSlug: press.slug });
112
+ setState({
113
+ status: "ready",
114
+ document,
115
+ deploymentInfo,
116
+ manifest,
117
+ activeSlug: press.slug,
118
+ runtimeMode: resolveRuntimeMode(document, route.mode),
119
+ });
100
120
  }, []);
101
121
 
102
122
  const refreshDocument = useCallback(async () => {
@@ -112,12 +132,12 @@ export function OpenPressApp() {
112
132
  });
113
133
  }, [state]);
114
134
 
115
- // Gallery click → pushState + load. Bypasses resolveFromSlug's
135
+ // Gallery click → pushState + load. Bypasses resolveFromRoute's
116
136
  // "empty slug + multi-Press → gallery" branch: an explicit click on
117
137
  // the unslugged root Press must enter it, not bounce back to gallery.
118
138
  const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
119
139
  if (state.status !== "gallery") return;
120
- pushSlug(press.slug);
140
+ pushPressRoute(press.slug, "preview");
121
141
  setState({ status: "loading" });
122
142
  try {
123
143
  const document = await loadReaderDocument(press.documentUrl);
@@ -127,6 +147,7 @@ export function OpenPressApp() {
127
147
  deploymentInfo: state.deploymentInfo,
128
148
  manifest: state.manifest,
129
149
  activeSlug: press.slug,
150
+ runtimeMode: "preview",
130
151
  });
131
152
  } catch (error) {
132
153
  setState({
@@ -147,7 +168,7 @@ export function OpenPressApp() {
147
168
  loadDeploymentInfo(),
148
169
  ]);
149
170
  if (cancelled) return;
150
- await resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
171
+ await resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
151
172
  } catch (error) {
152
173
  if (!cancelled) {
153
174
  setState({
@@ -162,7 +183,7 @@ export function OpenPressApp() {
162
183
  return () => {
163
184
  cancelled = true;
164
185
  };
165
- }, [resolveFromSlug]);
186
+ }, [resolveFromRoute]);
166
187
 
167
188
  // Back / forward button — re-resolve from the new URL.
168
189
  useEffect(() => {
@@ -176,11 +197,11 @@ export function OpenPressApp() {
176
197
  const deploymentInfo = state.status === "gallery" || state.status === "ready"
177
198
  ? state.deploymentInfo
178
199
  : offlineDeploymentInfo;
179
- void resolveFromSlug(manifest, currentSlugFromLocation(), deploymentInfo);
200
+ void resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
180
201
  }
181
202
  window.addEventListener("popstate", onPopState);
182
203
  return () => window.removeEventListener("popstate", onPopState);
183
- }, [state, resolveFromSlug]);
204
+ }, [state, resolveFromRoute]);
184
205
 
185
206
  if (state.status === "loading") return <LoadingScreen />;
186
207
 
@@ -197,7 +218,7 @@ export function OpenPressApp() {
197
218
  const backToWorkspace = state.manifest && manifestHasMultiplePresses(state.manifest)
198
219
  ? () => {
199
220
  if (state.status !== "ready" || !state.manifest) return;
200
- pushSlug("");
221
+ pushPressRoute("", "preview");
201
222
  setState({
202
223
  status: "gallery",
203
224
  manifest: state.manifest,
@@ -206,49 +227,97 @@ export function OpenPressApp() {
206
227
  }
207
228
  : undefined;
208
229
 
230
+ const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
231
+ const openPresentation = state.document.meta.type === "slides" && presentationSlug
232
+ ? (pageIndex: number) => {
233
+ openPressRoute(presentationSlug, "present", pageIndex, { fullscreen: true });
234
+ }
235
+ : undefined;
236
+
237
+ const exitPresentation = state.document.meta.type === "slides"
238
+ ? (pageIndex: number) => {
239
+ if (state.status !== "ready") return;
240
+ const slug = state.activeSlug || currentRouteFromLocation().slug;
241
+ if (slug) pushPressRoute(slug, "preview", pageIndex);
242
+ setState((latest) => latest.status === "ready"
243
+ ? { ...latest, runtimeMode: "preview" }
244
+ : latest);
245
+ }
246
+ : undefined;
247
+
209
248
  return (
210
249
  <OpenPressRuntime
211
250
  document={state.document}
251
+ runtimeMode={state.runtimeMode}
212
252
  deploymentInfo={state.deploymentInfo}
213
253
  onDocumentRefresh={refreshDocument}
254
+ onOpenPresentation={openPresentation}
255
+ onExitPresentation={exitPresentation}
214
256
  onBackToWorkspace={backToWorkspace}
215
257
  />
216
258
  );
217
259
  }
218
260
 
219
- function currentSlugFromLocation(): string {
220
- if (typeof window === "undefined") return "";
221
- return slugFromWorkspacePathname(window.location.pathname);
261
+ function currentRouteFromLocation(): WorkspaceRoute {
262
+ if (typeof window === "undefined") return { slug: "", mode: "preview" };
263
+ return routeFromWorkspacePathname(window.location.pathname);
222
264
  }
223
265
 
224
266
  function normalizeSlug(raw: string): string {
225
267
  return raw.replace(/^\/+|\/+$/g, "");
226
268
  }
227
269
 
228
- function slugFromWorkspacePathname(pathname: string): string {
270
+ function routeFromWorkspacePathname(pathname: string): WorkspaceRoute {
229
271
  const normalized = normalizeSlug(pathname);
230
- if (!normalized || normalized === "workspace") return "";
272
+ if (!normalized || normalized === "workspace") return { slug: "", mode: "preview" };
231
273
 
232
274
  const segments = normalized.split("/").filter(Boolean);
233
- if (segments.length === 2 && segments[1] === "preview") {
234
- return segments[0] ?? "";
275
+ if (segments.length === 2 && (segments[1] === "preview" || segments[1] === "present")) {
276
+ return { slug: segments[0] ?? "", mode: segments[1] };
235
277
  }
236
278
 
237
279
  // Legacy static/public route compatibility. New workspace navigation
238
280
  // writes /workspace and /<press-slug>/preview.
239
- return normalized;
281
+ return { slug: normalized, mode: "preview" };
240
282
  }
241
283
 
242
- function pushSlug(slug: string) {
284
+ function pushPressRoute(slug: string, mode: OpenPressRuntimeMode, pageIndex?: number) {
243
285
  if (typeof window === "undefined") return;
244
- // Drop query + hash: workbench routing is path-based, and page anchors
245
- // do not transfer across documents.
246
- const pathname = slug ? `/${normalizeSlug(slug)}/preview` : "/workspace";
247
- const target = pathname;
248
- if (window.location.pathname === pathname) return;
286
+ const target = buildPressRoute(slug, mode, pageIndex);
287
+ if (`${window.location.pathname}${window.location.search}${window.location.hash}` === target) return;
249
288
  window.history.pushState({}, "", target);
250
289
  }
251
290
 
291
+ function openPressRoute(
292
+ slug: string,
293
+ mode: OpenPressRuntimeMode,
294
+ pageIndex?: number,
295
+ options: { fullscreen?: boolean } = {},
296
+ ) {
297
+ if (typeof window === "undefined") return;
298
+ window.open(buildPressRoute(slug, mode, pageIndex, options), "_blank", "noopener,noreferrer");
299
+ }
300
+
301
+ function buildPressRoute(
302
+ slug: string,
303
+ mode: OpenPressRuntimeMode,
304
+ pageIndex?: number,
305
+ options: { fullscreen?: boolean } = {},
306
+ ) {
307
+ const normalizedSlug = normalizeSlug(slug);
308
+ const pathname = normalizedSlug ? `/${normalizedSlug}/${mode}` : "/workspace";
309
+ const search = mode === "present" && options.fullscreen ? "?fullscreen=1" : "";
310
+ const pageHash = typeof pageIndex === "number"
311
+ ? `#page-${String(pageIndex + 1).padStart(2, "0")}`
312
+ : "";
313
+ return `${pathname}${search}${pageHash}`;
314
+ }
315
+
316
+ function resolveRuntimeMode(document: ReaderDocument, requestedMode: OpenPressRuntimeMode): OpenPressRuntimeMode {
317
+ if (requestedMode === "present" && document.meta.type === "slides") return "present";
318
+ return "preview";
319
+ }
320
+
252
321
  async function loadWorkspaceManifest(): Promise<WorkspaceManifest | null> {
253
322
  // Optional — older deployments don't ship workspace.json. The reader
254
323
  // falls back to /openpress/document.json directly when missing, which
@@ -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;