@open-press/core 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine/cli.mjs +1 -1
- package/engine/commands/_shared.mjs +13 -2
- package/engine/commands/image.mjs +11 -1
- package/engine/output/chrome-pdf.mjs +155 -52
- package/engine/react/document-entry.mjs +9 -1
- package/engine/react/document-export.mjs +13 -1
- package/engine/react/press-tree-inspection.mjs +2 -0
- package/engine/react/text-source-transform.mjs +175 -0
- package/engine/runtime/inspection.mjs +65 -7
- package/engine/runtime/page-selector.mjs +87 -0
- package/engine/runtime/source-text-tools.mjs +71 -6
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +97 -28
- package/src/openpress/app/OpenPressRuntime.tsx +27 -2
- package/src/openpress/core/Press.tsx +1 -0
- package/src/openpress/core/types.ts +6 -6
- package/src/openpress/document-model/documentTypes.ts +3 -0
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -0
- package/src/openpress/reader/SlidePresentationPage.tsx +221 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/reader/usePanelState.ts +7 -4
- package/src/openpress/shared/runtimeMode.ts +7 -0
- package/src/openpress/workbench/Workbench.tsx +30 -2
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +84 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +7 -0
- package/src/styles/openpress/reader-runtime.css +11 -53
- package/src/styles/openpress/workbench-panels.css +1 -0
- package/src/styles/openpress/workbench.css +149 -0
|
@@ -139,9 +139,51 @@ export async function inspectRenderedOverflow({ root, config, host = "127.0.0.1"
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
export
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
795
|
-
throw new Error(`
|
|
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,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
|
-
// +
|
|
67
|
-
const
|
|
72
|
+
// + route, decides whether to render gallery or load a press.
|
|
73
|
+
const resolveFromRoute = useCallback(async (
|
|
68
74
|
manifest: WorkspaceManifest | null,
|
|
69
|
-
|
|
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({
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}, [
|
|
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
|
|
200
|
+
void resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
|
|
180
201
|
}
|
|
181
202
|
window.addEventListener("popstate", onPopState);
|
|
182
203
|
return () => window.removeEventListener("popstate", onPopState);
|
|
183
|
-
}, [state,
|
|
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
|
-
|
|
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
|
|
220
|
-
if (typeof window === "undefined") return "";
|
|
221
|
-
return
|
|
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
|
|
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
|
|
284
|
+
function pushPressRoute(slug: string, mode: OpenPressRuntimeMode, pageIndex?: number) {
|
|
243
285
|
if (typeof window === "undefined") return;
|
|
244
|
-
|
|
245
|
-
|
|
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
|
);
|