@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.
@@ -1,5 +1,5 @@
1
1
  import type { HTMLAttributes, ReactNode } from "react";
2
- import type { EditableSourceRef, ObjectEntityKind } from "../document-model/documentTypes";
2
+ import type { EditableSourceRef, ObjectEntityKind, PressType } from "../document-model/documentTypes";
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Frame / MdxArea / Press primitives
@@ -52,6 +52,9 @@ export interface PressProps {
52
52
  // Document title. Required in 1.0. Used for PDF metadata, HTML <title>,
53
53
  // OG tags, and the Workspace gallery / tab-bar label.
54
54
  title?: string;
55
+ // Creation mode. Pages are source-driven with MDX allocation; slides are
56
+ // explicit one-frame-per-page documents. Defaults to "pages".
57
+ type?: PressType;
55
58
  // Page geometry preset name or a custom geometry object. Optional;
56
59
  // workspace default applies if not set.
57
60
  page?: "a4" | "social-square" | "slide-16-9" | PageGeometry;
@@ -118,7 +121,7 @@ export type BaseCalloutProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
118
121
  children: ReactNode;
119
122
  };
120
123
 
121
- export type ObjectEntityElement = "span" | "div" | "section" | "article" | "figure" | "p";
124
+ export type ObjectEntityElement = keyof HTMLElementTagNameMap;
122
125
 
123
126
  export type ObjectEntityProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
124
127
  as?: ObjectEntityElement;
@@ -135,9 +138,7 @@ export type ObjectEntityProps = Omit<HTMLAttributes<HTMLElement>, "children"> &
135
138
  children?: ReactNode;
136
139
  };
137
140
 
138
- export type TextProps = Omit<ObjectEntityProps, "kind"> & {
139
- as?: "span" | "div" | "p";
140
- };
141
+ export type TextProps = Omit<ObjectEntityProps, "kind">;
141
142
 
142
143
  // ---------------------------------------------------------------------------
143
144
  // Source descriptors and resolved sources
@@ -233,4 +234,3 @@ export interface ResolvedSource {
233
234
  // Per-frame, per-chain, ordered list of React nodes assigned to each
234
235
  // MdxArea by area index.
235
236
  export type FrameAllocation = Record<string, Record<string, ReactNode[]>>;
236
-
@@ -18,6 +18,8 @@ export interface ReaderDocument {
18
18
  blocks: HtmlPageBlock[];
19
19
  }
20
20
 
21
+ export type PressType = "pages" | "slides";
22
+
21
23
  export interface DocumentSource {
22
24
  type: string;
23
25
  contentDir?: string;
@@ -57,6 +59,7 @@ export interface SourceBlock {
57
59
 
58
60
  export interface DocumentMeta {
59
61
  title: string;
62
+ type?: PressType;
60
63
  subtitle?: string;
61
64
  organization?: string;
62
65
  version?: string;
@@ -5,6 +5,7 @@
5
5
  // Single-Press workspaces emit one entry with slug = "" and the
6
6
  // legacy /openpress/document.json path. Multi-Press emits one entry
7
7
  // per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
8
+ import type { PressType } from "./documentTypes";
8
9
 
9
10
  export interface WorkspaceManifest {
10
11
  version: 1;
@@ -20,6 +21,9 @@ export interface WorkspaceManifestPress {
20
21
  slug: string;
21
22
  // <Press title="..."> prop. Required in v1.0 contract.
22
23
  title: string;
24
+ // Creation mode declared by <Press type>. Defaults to "pages" for older
25
+ // documents. The reader uses this for mode-specific navigation affordances.
26
+ type: PressType;
23
27
  // Page geometry summary. Same shape as the reader's
24
28
  // ReaderDocument.theme — readers can show a thumb in the gallery
25
29
  // without loading the full document.json.
@@ -0,0 +1,221 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
2
+ import { Maximize2, X } from "lucide-react";
3
+ import { createPageObjectEntityId } from "../document-model";
4
+ import type { HtmlPageBlock, ReaderDocument } from "../document-model";
5
+ import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
6
+ import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel";
7
+ import { usePageViewportScale } from "./usePageViewportScale";
8
+
9
+ type PresentationUiMode = "chrome" | "immersive";
10
+
11
+ export function SlidePresentationPage({
12
+ document,
13
+ pages,
14
+ style,
15
+ onExitPresentation,
16
+ }: {
17
+ document: ReaderDocument;
18
+ pages: HtmlPageBlock[];
19
+ style: CSSProperties;
20
+ onExitPresentation?: (pageIndex: number) => void;
21
+ }) {
22
+ const sourceContainerRef = useRef<HTMLDivElement | null>(null);
23
+ const stageRef = useRef<HTMLElement | null>(null);
24
+ const currentPageIndexRef = useRef(0);
25
+ const normalizedPageCount = normalizeReaderPageCount(pages.length);
26
+ const [currentPageIndex, setCurrentPageIndex] = useState(() => {
27
+ if (typeof window === "undefined") return 0;
28
+ return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0;
29
+ });
30
+ const [uiMode, setUiMode] = useState<PresentationUiMode>(() => (
31
+ shouldStartImmersive() ? "immersive" : "chrome"
32
+ ));
33
+ const pageViewport = usePageViewportScale({
34
+ stageRef,
35
+ pageContainerRef: sourceContainerRef,
36
+ pageCount: pages.length,
37
+ layoutMode: "single",
38
+ });
39
+ const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)];
40
+ const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1);
41
+ const totalPageLabel = formatReaderPageNumber(normalizedPageCount);
42
+ const setPage = useCallback((pageIndex: number) => {
43
+ const target = clampReaderPageIndex(pageIndex, normalizedPageCount);
44
+ setCurrentPageIndex(target);
45
+ replacePageRoute(target);
46
+ }, [normalizedPageCount]);
47
+
48
+ currentPageIndexRef.current = currentPageIndex;
49
+
50
+ useEffect(() => {
51
+ setCurrentPageIndex((idx) => clampReaderPageIndex(idx, normalizedPageCount));
52
+ }, [normalizedPageCount]);
53
+
54
+ useEffect(() => {
55
+ const syncPageFromHash = () => {
56
+ const fromHash = pageIndexFromHash(window.location.hash, normalizedPageCount);
57
+ if (fromHash !== null) setCurrentPageIndex(fromHash);
58
+ };
59
+
60
+ syncPageFromHash();
61
+ window.addEventListener("hashchange", syncPageFromHash);
62
+ return () => window.removeEventListener("hashchange", syncPageFromHash);
63
+ }, [normalizedPageCount]);
64
+
65
+ useEffect(() => {
66
+ const handleKeyDown = (event: KeyboardEvent) => {
67
+ if (isEditableTarget(event.target)) return;
68
+ if (event.key === "Escape") {
69
+ event.preventDefault();
70
+ const activeDocument = globalThis.document;
71
+ if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
72
+ void activeDocument.exitFullscreen();
73
+ return;
74
+ }
75
+ onExitPresentation?.(currentPageIndexRef.current);
76
+ return;
77
+ }
78
+ if (event.key === " " || event.code === "Space") {
79
+ event.preventDefault();
80
+ setPage(currentPageIndexRef.current + 1);
81
+ } else if (event.key === "ArrowRight" || event.key === "PageDown") {
82
+ event.preventDefault();
83
+ setPage(currentPageIndexRef.current + 1);
84
+ } else if (event.key === "ArrowLeft" || event.key === "PageUp") {
85
+ event.preventDefault();
86
+ setPage(currentPageIndexRef.current - 1);
87
+ } else if (event.key === "Home") {
88
+ event.preventDefault();
89
+ setPage(0);
90
+ } else if (event.key === "End") {
91
+ event.preventDefault();
92
+ setPage(normalizedPageCount - 1);
93
+ }
94
+ };
95
+
96
+ window.addEventListener("keydown", handleKeyDown);
97
+ return () => window.removeEventListener("keydown", handleKeyDown);
98
+ }, [normalizedPageCount, onExitPresentation, setPage]);
99
+
100
+ useEffect(() => {
101
+ const handleFullscreenChange = () => {
102
+ setUiMode(globalThis.document.fullscreenElement ? "immersive" : "chrome");
103
+ };
104
+
105
+ globalThis.document.addEventListener("fullscreenchange", handleFullscreenChange);
106
+ return () => globalThis.document.removeEventListener("fullscreenchange", handleFullscreenChange);
107
+ }, []);
108
+
109
+ useEffect(() => {
110
+ if (!shouldStartImmersive()) return;
111
+ enterImmersive({ keepOnFailure: true });
112
+ }, []);
113
+
114
+ const handleStageClick = (event: ReactMouseEvent<HTMLElement>) => {
115
+ if (event.defaultPrevented) return;
116
+ if (!(event.target instanceof Element)) return;
117
+ if (event.target.closest("a, button, input, textarea, select, [contenteditable]")) return;
118
+ setPage(currentPageIndex + 1);
119
+ };
120
+
121
+ const handleFullscreen = () => {
122
+ enterImmersive({ keepOnFailure: false });
123
+ };
124
+
125
+ const enterImmersive = ({ keepOnFailure }: { keepOnFailure: boolean }) => {
126
+ const stage = stageRef.current;
127
+ if (!stage?.requestFullscreen) {
128
+ setUiMode("immersive");
129
+ return;
130
+ }
131
+ setUiMode("immersive");
132
+ void stage.requestFullscreen().catch(() => {
133
+ if (!keepOnFailure) setUiMode("chrome");
134
+ });
135
+ };
136
+
137
+ const pageHtml = useMemo(() => currentPage?.html ?? "", [currentPage?.html]);
138
+
139
+ return (
140
+ <main
141
+ className="openpress-workbench openpress-reader-app openpress-slide-presenter"
142
+ style={style}
143
+ data-openpress-react-runtime="true"
144
+ data-openpress-view-mode="paged"
145
+ data-openpress-press-type="slides"
146
+ data-openpress-presentation-mode="on"
147
+ data-openpress-present-ui={uiMode}
148
+ data-openpress-slide-presenter
149
+ aria-label={`${document.meta.title} 放映模式`}
150
+ >
151
+ <section
152
+ className="openpress-slide-presenter__stage"
153
+ data-openpress-present-stage
154
+ aria-label="投影片放映區"
155
+ onClick={handleStageClick}
156
+ >
157
+ <main className="reader-stage" tabIndex={-1} ref={stageRef}>
158
+ <div
159
+ className="reader-pages openpress-public-page openpress-slide-presenter__pages"
160
+ ref={sourceContainerRef}
161
+ data-openpress-public-page="true"
162
+ data-openpress-page-layout="single"
163
+ >
164
+ {currentPage ? (
165
+ <div
166
+ key={currentPage.id}
167
+ id={`page-${String(currentPage.pageNumber).padStart(2, "0")}`}
168
+ className="openpress-html-page"
169
+ data-openpress-object-id={currentPage.frameKey ? createPageObjectEntityId(currentPage.frameKey) : undefined}
170
+ data-openpress-page-index={currentPage.pageNumber - 1}
171
+ data-openpress-active="true"
172
+ >
173
+ <div className="openpress-html-page__html" dangerouslySetInnerHTML={{ __html: pageHtml }} />
174
+ </div>
175
+ ) : null}
176
+ </div>
177
+ </main>
178
+ </section>
179
+
180
+ <div className="openpress-slide-presenter__hud" aria-label="放映控制">
181
+ <span
182
+ className="openpress-slide-presenter__progress"
183
+ data-openpress-present-progress
184
+ data-openpress-present-scale={pageViewport.scaleMode}
185
+ >
186
+ {currentPageLabel} / {totalPageLabel}
187
+ </span>
188
+ <button
189
+ type="button"
190
+ className="openpress-slide-presenter__button"
191
+ data-openpress-present-fullscreen
192
+ onClick={handleFullscreen}
193
+ aria-label="進入全螢幕"
194
+ title="進入全螢幕"
195
+ >
196
+ <Maximize2 aria-hidden="true" />
197
+ </button>
198
+ <button
199
+ type="button"
200
+ className="openpress-slide-presenter__button"
201
+ data-openpress-present-exit
202
+ onClick={() => onExitPresentation?.(currentPageIndex)}
203
+ aria-label="回到工作台"
204
+ title="回到工作台"
205
+ >
206
+ <X aria-hidden="true" />
207
+ </button>
208
+ </div>
209
+ </main>
210
+ );
211
+ }
212
+
213
+ function isEditableTarget(target: EventTarget | null) {
214
+ if (!(target instanceof HTMLElement)) return false;
215
+ return Boolean(target.closest("input, textarea, select, button, [contenteditable]"));
216
+ }
217
+
218
+ function shouldStartImmersive() {
219
+ if (typeof window === "undefined") return false;
220
+ return new URLSearchParams(window.location.search).get("fullscreen") === "1";
221
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./PageThumbnailsPanel";
2
2
  export * from "./PublicReaderPage";
3
3
  export * from "./ReaderNavigationPanel";
4
+ export * from "./SlidePresentationPage";
4
5
  export * from "./pageViewportScaleModel";
5
6
  export * from "./readerPageRegistry";
6
7
  export * from "./readerPageRoute";
@@ -35,9 +35,12 @@ export function usePanelState({
35
35
  if (typeof window === "undefined") return undefined;
36
36
 
37
37
  const handleResize = () => {
38
- setLeftPanelOpen((open) => (open && !shouldOpenLeftPanel() ? false : open));
39
- setRightPanelOpen((open) => (open && !shouldOpenRightPanel() ? false : open));
40
- onAfterResize?.();
38
+ const closeLeftPanel = leftPanelOpen && !shouldOpenLeftPanel();
39
+ const closeRightPanel = rightPanelOpen && !shouldOpenRightPanel();
40
+
41
+ if (closeLeftPanel) setLeftPanelOpen(false);
42
+ if (closeRightPanel) setRightPanelOpen(false);
43
+ if (closeLeftPanel || closeRightPanel) onAfterResize?.();
41
44
  };
42
45
 
43
46
  handleResize();
@@ -47,7 +50,7 @@ export function usePanelState({
47
50
  window.removeEventListener("resize", handleResize);
48
51
  window.visualViewport?.removeEventListener("resize", handleResize);
49
52
  };
50
- }, [shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
53
+ }, [leftPanelOpen, rightPanelOpen, shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
51
54
 
52
55
  const toggleLeftPanel = useCallback(() => setLeftPanelOpen((open) => !open), []);
53
56
  const toggleRightPanel = useCallback(() => setRightPanelOpen((open) => !open), []);
@@ -10,6 +10,13 @@ export function isWorkspaceModeLocation(location: Pick<Location, "hostname" | "p
10
10
  return segments.length === 2 && segments[1] === "preview";
11
11
  }
12
12
 
13
+ export function isPresentationModeLocation(location: Pick<Location, "hostname" | "pathname">) {
14
+ if (!isLocalWorkspaceHost(location.hostname)) return false;
15
+ const pathname = normalizePathname(location.pathname);
16
+ const segments = pathname.split("/").filter(Boolean);
17
+ return segments.length === 2 && segments[1] === "present";
18
+ }
19
+
13
20
  export function isPrintModeLocation(location: Pick<Location, "search">) {
14
21
  return new URLSearchParams(location.search).has("print");
15
22
  }
@@ -4,7 +4,7 @@ import {
4
4
  useState,
5
5
  type CSSProperties,
6
6
  } from "react";
7
- import { ExternalLink, Home, MousePointer2, Ruler } from "lucide-react";
7
+ import { ExternalLink, Home, MousePointer2, Play, Ruler } from "lucide-react";
8
8
  import {
9
9
  getProjectIdentity,
10
10
  resolveAnchorPageIndex,
@@ -55,6 +55,7 @@ export function HtmlWorkbench({
55
55
  deploymentInfo,
56
56
  onDocumentRefresh,
57
57
  onBackToWorkspace,
58
+ onOpenPresentation,
58
59
  extraControlPanels,
59
60
  }: {
60
61
  document: ReaderDocument;
@@ -64,6 +65,7 @@ export function HtmlWorkbench({
64
65
  deploymentInfo: DeploymentInfo;
65
66
  onDocumentRefresh?: () => void | Promise<void>;
66
67
  onBackToWorkspace?: () => void;
68
+ onOpenPresentation?: (pageIndex: number) => void;
67
69
  // Append extra panels into the right-side control panel. Built-in panels
68
70
  // (pending comments + project entry) render first; extra panels render
69
71
  // after them in the supplied order.
@@ -99,6 +101,8 @@ export function HtmlWorkbench({
99
101
  const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
100
102
 
101
103
  const projectIdentity = getProjectIdentity(document.meta);
104
+ const pressType = normalizePressType(document.meta.type);
105
+ const isSlidePress = pressType === "slides";
102
106
  const pageGeometry = formatPageGeometrySpec(document.theme);
103
107
  const inspectorSelectionLabel = formatInspectorSelection(
104
108
  inspector.selectedBlock,
@@ -284,6 +288,22 @@ export function HtmlWorkbench({
284
288
  />
285
289
  </div>
286
290
  <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
291
+ {isSlidePress && onOpenPresentation ? (
292
+ <button
293
+ type="button"
294
+ className="openpress-workbench-toolbar-action"
295
+ data-openpress-slide-present
296
+ data-openpress-toolbar-expanded="false"
297
+ data-openpress-toolbar-active="false"
298
+ aria-pressed="false"
299
+ title="進入放映模式"
300
+ aria-label="進入放映模式"
301
+ onClick={() => onOpenPresentation?.(reader.currentPageIndex)}
302
+ >
303
+ <Play aria-hidden="true" />
304
+ <span className="openpress-workbench-toolbar-action__label">放映</span>
305
+ </button>
306
+ ) : null}
287
307
  <button
288
308
  type="button"
289
309
  className="openpress-workbench-page-geometry"
@@ -386,8 +406,10 @@ export function HtmlWorkbench({
386
406
  pageViewport.scaleMode,
387
407
  pageViewport.setScaleMode,
388
408
  selectWorkspacePage,
409
+ isSlidePress,
389
410
  sourceBlocksByPath,
390
411
  onBackToWorkspace,
412
+ onOpenPresentation,
391
413
  reader.currentPageIndex,
392
414
  reader.currentPageLabel,
393
415
  projectIdentity.name,
@@ -398,6 +420,8 @@ export function HtmlWorkbench({
398
420
  style={style}
399
421
  devMode={devMode}
400
422
  viewMode={viewMode}
423
+ pressType={pressType}
424
+ presentationMode={false}
401
425
  inspectorMode={inspector.inspectorMode}
402
426
  editMode={inlineEditEnabled}
403
427
  leftPanelOpen={reader.leftPanelOpen}
@@ -418,7 +442,7 @@ export function HtmlWorkbench({
418
442
  {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
419
443
  </section>
420
444
 
421
- {bookmarks.length > 0 ? (
445
+ {!isSlidePress && bookmarks.length > 0 ? (
422
446
  <section
423
447
  id="openpress-bookmarks"
424
448
  className="openpress-panel-section openpress-panel-section--bookmarks"
@@ -504,3 +528,7 @@ function formatInlineEditStatus(status: InlineDocumentEditStatus) {
504
528
  if (status.state === "failed") return "儲存失敗";
505
529
  return "";
506
530
  }
531
+
532
+ function normalizePressType(value: ReaderDocument["meta"]["type"]) {
533
+ return value === "slides" ? "slides" : "pages";
534
+ }
@@ -32,6 +32,8 @@ type DocumentWithCaretFromPoint = Document & {
32
32
 
33
33
  const EDITABLE_SELECTOR = "[data-openpress-editable-block='true']";
34
34
  const SOURCE_SELECTOR = "[data-openpress-source-editable-block='true']";
35
+ const EDITABLE_OBJECT_TEXT_SELECTOR = "[data-openpress-object-kind='text'][data-openpress-object-source]";
36
+ const EDITABLE_SOURCE_TARGET_SELECTOR = `[data-openpress-block-id], ${EDITABLE_OBJECT_TEXT_SELECTOR}`;
35
37
  const SAVED_EDIT_STATE_RESET_DELAY_MS = 900;
36
38
  const UNSAFE_EDITABLE_CHILDREN = [
37
39
  "a",
@@ -50,7 +52,6 @@ const UNSAFE_EDITABLE_CHILDREN = [
50
52
  "ul",
51
53
  "video",
52
54
  ].join(",");
53
- const EDITABLE_COMPONENT_CAPTION_NAMES = new Set(["MediaFigure", "ImageFigure"]);
54
55
 
55
56
  export function useInlineDocumentEditor({
56
57
  enabled,
@@ -233,7 +234,7 @@ function markEditableElements(
233
234
  sourceBlockMap: Record<string, SourceBlock>,
234
235
  markedElements: Set<HTMLElement>,
235
236
  ) {
236
- root.querySelectorAll<HTMLElement>("[data-openpress-block-id]").forEach((element) => {
237
+ root.querySelectorAll<HTMLElement>(EDITABLE_SOURCE_TARGET_SELECTOR).forEach((element) => {
237
238
  const sourceBlock = blockFromElement(element, sourceBlockMap);
238
239
  if (sourceBlock?.kind === "table-row") {
239
240
  markEditableTableCells(element, sourceBlock, markedElements);
@@ -244,6 +245,15 @@ function markEditableElements(
244
245
  return;
245
246
  }
246
247
 
248
+ if (sourceBlock?.kind === "object-text") {
249
+ element.dataset.openpressBlockId = sourceBlock.id;
250
+ element.dataset.openpressInheritedBlockId = "true";
251
+ element.dataset.openpressEditKind = "object-text";
252
+ element.dataset.openpressEditName = "text";
253
+ markEditableTextElement(element, markedElements, { label: "編輯文字" });
254
+ return;
255
+ }
256
+
247
257
  if (isEditableTextBlockElement(element, sourceBlockMap)) {
248
258
  markEditableTextElement(element, markedElements, {
249
259
  label: sourceBlock?.name === "pre" ? "編輯程式碼文字" : "編輯文字",
@@ -267,7 +277,6 @@ function markEditableComponentCaption(
267
277
  markedElements: Set<HTMLElement>,
268
278
  ) {
269
279
  if (sourceBlock.kind !== "component") return false;
270
- if (!EDITABLE_COMPONENT_CAPTION_NAMES.has(String(sourceBlock.name))) return false;
271
280
  if (!sourceBlock.path || !sourceBlock.source?.line) return false;
272
281
 
273
282
  const caption = componentElement.querySelector<HTMLElement>("figcaption");
@@ -407,7 +416,76 @@ function eventTargetElement(event: Event) {
407
416
 
408
417
  function blockFromElement(element: HTMLElement, sourceBlockMap: Record<string, SourceBlock>) {
409
418
  const blockId = element.dataset.openpressBlockId;
410
- return blockId ? sourceBlockMap[blockId] : undefined;
419
+ if (blockId && sourceBlockMap[blockId]) return sourceBlockMap[blockId];
420
+ return sourceBlockFromObjectElement(element);
421
+ }
422
+
423
+ function sourceBlockFromObjectElement(element: HTMLElement): SourceBlock | undefined {
424
+ if (element.dataset.openpressObjectKind !== "text") return undefined;
425
+ const sourceRef = parseObjectSourceRef(element.dataset.openpressObjectSource);
426
+ if (typeof sourceRef?.path !== "string" || !sourceRef.path) return undefined;
427
+ const source = sourceLocationFromSourceRef(sourceRef);
428
+ if (!source?.line) return undefined;
429
+ const objectId = element.dataset.openpressObjectId || (typeof sourceRef.objectId === "string" ? sourceRef.objectId : undefined);
430
+ if (!objectId) return undefined;
431
+ return {
432
+ id: `object-text:${objectId}`,
433
+ kind: "object-text",
434
+ name: "text",
435
+ path: sourceRef.path,
436
+ source,
437
+ frameKey: element.dataset.openpressObjectFrameKey,
438
+ chainId: element.dataset.openpressObjectChainId,
439
+ };
440
+ }
441
+
442
+ type ObjectSourceRefCandidate = {
443
+ path?: unknown;
444
+ objectId?: unknown;
445
+ source?: unknown;
446
+ line?: unknown;
447
+ column?: unknown;
448
+ endLine?: unknown;
449
+ endColumn?: unknown;
450
+ };
451
+
452
+ type SourceLocationCandidate = {
453
+ line?: unknown;
454
+ column?: unknown;
455
+ endLine?: unknown;
456
+ endColumn?: unknown;
457
+ };
458
+
459
+ function parseObjectSourceRef(value: string | undefined): ObjectSourceRefCandidate | undefined {
460
+ if (!value) return undefined;
461
+ try {
462
+ const parsed = JSON.parse(value) as unknown;
463
+ return parsed && typeof parsed === "object" ? parsed : undefined;
464
+ } catch {
465
+ return undefined;
466
+ }
467
+ }
468
+
469
+ function sourceLocationFromSourceRef(sourceRef: ReturnType<typeof parseObjectSourceRef>): SourceBlock["source"] | undefined {
470
+ if (!sourceRef) return undefined;
471
+ const nestedSource = sourceLocationCandidate(sourceRef.source);
472
+ const line = numberValue(sourceRef.line) ?? numberValue(nestedSource?.line);
473
+ if (line === undefined) return undefined;
474
+ return {
475
+ line,
476
+ column: numberValue(sourceRef.column) ?? numberValue(nestedSource?.column) ?? 1,
477
+ endLine: numberValue(sourceRef.endLine) ?? numberValue(nestedSource?.endLine),
478
+ endColumn: numberValue(sourceRef.endColumn) ?? numberValue(nestedSource?.endColumn),
479
+ };
480
+ }
481
+
482
+ function sourceLocationCandidate(value: unknown): SourceLocationCandidate | undefined {
483
+ if (!value || typeof value !== "object") return undefined;
484
+ return value;
485
+ }
486
+
487
+ function numberValue(value: unknown): number | undefined {
488
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
411
489
  }
412
490
 
413
491
  async function persistElementEdit(
@@ -417,8 +495,8 @@ async function persistElementEdit(
417
495
  onStatusChange: InlineDocumentEditorOptions["onStatusChange"],
418
496
  onDocumentEdited: InlineDocumentEditorOptions["onDocumentEdited"],
419
497
  ) {
420
- const blockId = element.dataset.openpressBlockId;
421
- const sourceBlock = blockId ? sourceBlockMap[blockId] : undefined;
498
+ const sourceBlock = blockFromElement(element, sourceBlockMap);
499
+ const blockId = sourceBlock?.id ?? element.dataset.openpressBlockId;
422
500
  const preserveLineBreaks = element.dataset.openpressPreserveLineBreaks === "true";
423
501
  const originalText = normalizeEditableText(element.dataset.openpressOriginalText ?? "", { preserveLineBreaks });
424
502
  const nextText = normalizeEditableText(readableElementText(element), { preserveLineBreaks });
@@ -20,6 +20,8 @@ function WorkbenchShellRoot({
20
20
  style,
21
21
  devMode,
22
22
  viewMode,
23
+ pressType = "pages",
24
+ presentationMode = false,
23
25
  inspectorMode,
24
26
  editMode = false,
25
27
  leftPanelOpen,
@@ -31,6 +33,8 @@ function WorkbenchShellRoot({
31
33
  style: CSSProperties;
32
34
  devMode: boolean;
33
35
  viewMode: string;
36
+ pressType?: string;
37
+ presentationMode?: boolean;
34
38
  inspectorMode: boolean;
35
39
  editMode?: boolean;
36
40
  leftPanelOpen: boolean;
@@ -45,6 +49,7 @@ function WorkbenchShellRoot({
45
49
  "reader-app openpress-reader-app openpress-public-viewer openpress-dev-public-viewer openpress-workbench-shell is-ready",
46
50
  leftPanelOpen ? "" : "is-closed-left",
47
51
  rightPanelOpen ? "" : "is-closed-right",
52
+ presentationMode ? "is-presentation-mode" : "",
48
53
  ].filter(Boolean).join(" ");
49
54
 
50
55
  return (
@@ -54,6 +59,8 @@ function WorkbenchShellRoot({
54
59
  className={shellClassName}
55
60
  data-openpress-react-runtime="true"
56
61
  data-openpress-view-mode={viewMode}
62
+ data-openpress-press-type={pressType}
63
+ data-openpress-presentation-mode={presentationMode ? "on" : "off"}
57
64
  data-openpress-inspector-mode={inspectorMode ? "on" : "off"}
58
65
  data-openpress-edit-mode={editMode ? "on" : "off"}
59
66
  data-openpress-workbench-shell