@open-press/core 0.7.1 → 1.0.0

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 (144) hide show
  1. package/README.md +6 -3
  2. package/engine/cli.mjs +8 -8
  3. package/engine/commands/_shared.mjs +37 -15
  4. package/engine/commands/dev.mjs +2 -2
  5. package/engine/commands/image.mjs +29 -0
  6. package/engine/commands/skills-sync.mjs +71 -0
  7. package/engine/commands/typecheck.mjs +63 -1
  8. package/engine/commands/upgrade.mjs +3 -3
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/output/chrome-pdf.mjs +110 -3
  11. package/engine/output/static-server.mjs +87 -9
  12. package/engine/react/comment-endpoint.mjs +13 -39
  13. package/engine/react/comment-marker.mjs +43 -19
  14. package/engine/react/document-entry.mjs +46 -28
  15. package/engine/react/document-export.mjs +328 -164
  16. package/engine/react/http-json.mjs +24 -0
  17. package/engine/react/mdx-compile.mjs +126 -3
  18. package/engine/react/measurement-css.mjs +114 -1
  19. package/engine/react/object-entities.mjs +204 -0
  20. package/engine/react/pagination/allocator.mjs +48 -3
  21. package/engine/react/pagination.mjs +1 -1
  22. package/engine/react/pipeline/allocate.mjs +41 -72
  23. package/engine/react/pipeline/frame-measurement.mjs +6 -0
  24. package/engine/react/press-tree-inspection.mjs +172 -0
  25. package/engine/react/project-asset-endpoint.mjs +6 -24
  26. package/engine/react/source-edit-endpoint.d.mts +10 -0
  27. package/engine/react/source-edit-endpoint.mjs +75 -0
  28. package/engine/react/sources/mdx-resolver.mjs +13 -15
  29. package/engine/react/style-discovery.mjs +23 -8
  30. package/engine/runtime/config.d.mts +8 -0
  31. package/engine/runtime/config.mjs +57 -60
  32. package/engine/runtime/file-utils.mjs +9 -1
  33. package/engine/runtime/file-walk.mjs +22 -0
  34. package/engine/runtime/inspection.mjs +1 -20
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/path-utils.mjs +20 -0
  37. package/engine/runtime/source-text-tools.d.mts +102 -0
  38. package/engine/runtime/source-text-tools.mjs +551 -16
  39. package/engine/runtime/source-workspace.mjs +16 -34
  40. package/engine/runtime/validation.mjs +19 -10
  41. package/package.json +3 -5
  42. package/src/openpress/app/OpenPressApp.tsx +296 -0
  43. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/app/index.ts +2 -0
  46. package/src/openpress/core/Frame.tsx +26 -15
  47. package/src/openpress/core/FrameContext.tsx +10 -3
  48. package/src/openpress/core/MdxArea.tsx +11 -12
  49. package/src/openpress/core/Press.tsx +25 -4
  50. package/src/openpress/core/Workspace.tsx +36 -0
  51. package/src/openpress/core/cn.ts +4 -0
  52. package/src/openpress/core/index.tsx +11 -3
  53. package/src/openpress/core/primitives.tsx +74 -6
  54. package/src/openpress/core/types.ts +94 -41
  55. package/src/openpress/core/useSource.ts +1 -1
  56. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  57. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  58. package/src/openpress/{types.ts → document-model/documentTypes.ts} +51 -0
  59. package/src/openpress/document-model/index.ts +7 -0
  60. package/src/openpress/document-model/objectEntityModel.ts +55 -0
  61. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  62. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  63. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  64. package/src/openpress/manuscript/index.tsx +49 -7
  65. package/src/openpress/mdx/index.ts +15 -7
  66. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  67. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  68. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  69. package/src/openpress/reader/index.ts +11 -0
  70. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  71. package/src/openpress/reader/readerTypes.ts +4 -0
  72. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  73. package/src/openpress/reader/usePanelState.ts +56 -0
  74. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  75. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  76. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  77. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  78. package/src/openpress/shared/Panel.tsx +77 -0
  79. package/src/openpress/shared/index.ts +4 -0
  80. package/src/openpress/shared/numberUtils.ts +3 -0
  81. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  82. package/src/openpress/workbench/Workbench.tsx +506 -0
  83. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  84. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  85. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  86. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  87. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  88. package/src/openpress/workbench/actions/index.ts +6 -0
  89. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  90. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  91. package/src/openpress/workbench/dialog/index.ts +1 -0
  92. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  93. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  94. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  95. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  96. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  97. package/src/openpress/workbench/document/index.ts +10 -0
  98. package/src/openpress/workbench/index.ts +2 -0
  99. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  100. package/src/openpress/workbench/inspector/index.ts +5 -0
  101. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  102. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  103. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  104. package/src/openpress/workbench/inspector/useInspectorComments.ts +254 -0
  105. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  106. package/src/openpress/workbench/mentions/index.ts +2 -0
  107. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  108. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  109. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +80 -0
  110. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  111. package/src/openpress/workbench/panels/index.ts +3 -0
  112. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +525 -0
  113. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  114. package/src/openpress/workbench/project/index.ts +2 -0
  115. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  116. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  117. package/src/openpress/workbench/shell/index.ts +1 -0
  118. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  119. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  120. package/src/styles/openpress/print-route.css +0 -2
  121. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  122. package/src/styles/openpress/public-viewer.css +25 -320
  123. package/src/styles/openpress/reader-runtime.css +252 -55
  124. package/src/styles/openpress/responsive.css +145 -270
  125. package/src/styles/openpress/workbench-panels.css +327 -178
  126. package/src/styles/openpress/workbench.css +986 -451
  127. package/src/styles/openpress/workspace-gallery.css +300 -0
  128. package/src/styles/openpress.css +2 -1
  129. package/tsconfig.json +1 -1
  130. package/vite.config.ts +50 -0
  131. package/engine/commands/init.mjs +0 -24
  132. package/engine/init.mjs +0 -90
  133. package/src/openpress/App.tsx +0 -127
  134. package/src/openpress/inspector.ts +0 -282
  135. package/src/openpress/projectWorkspace.tsx +0 -919
  136. package/src/openpress/readerRuntime.ts +0 -230
  137. package/src/openpress/workbench.tsx +0 -1265
  138. package/src/openpress/workbenchTypes.ts +0 -4
  139. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  140. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  141. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  142. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  143. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  144. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -0,0 +1,55 @@
1
+ import type { ObjectEntity, ObjectEntityKind, ReaderDocument, SourceBlock } from "./documentTypes";
2
+
3
+ export function createObjectEntityId(kind: ObjectEntityKind, ...parts: Array<string | number>) {
4
+ return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
5
+ }
6
+
7
+ export function createScopedObjectEntityId(kind: ObjectEntityKind, parentId: string | undefined, objectId: string) {
8
+ return parentId ? createObjectEntityId(kind, parentId, objectId) : createObjectEntityId(kind, objectId);
9
+ }
10
+
11
+ export function createBlockObjectEntityId(blockId: string) {
12
+ return createObjectEntityId("mdx-block", blockId);
13
+ }
14
+
15
+ export function createFrameObjectEntityId(frameKey: string) {
16
+ return createObjectEntityId("frame", frameKey);
17
+ }
18
+
19
+ export function createPageObjectEntityId(frameKey: string) {
20
+ return createObjectEntityId("page", frameKey);
21
+ }
22
+
23
+ export function createMdxAreaObjectEntityId(frameKey: string, chainId: string, indexInFrame: number) {
24
+ return createObjectEntityId("mdx-area", frameKey, chainId, indexInFrame);
25
+ }
26
+
27
+ export function getObjectEntityMap(document: Pick<ReaderDocument, "source"> | null | undefined): Record<string, ObjectEntity> {
28
+ return document?.source?.objectEntities ?? {};
29
+ }
30
+
31
+ export function getObjectEntity(document: Pick<ReaderDocument, "source"> | null | undefined, objectId: string): ObjectEntity | null {
32
+ return getObjectEntityMap(document)[objectId] ?? null;
33
+ }
34
+
35
+ export function sourceBlockToObjectEntity(block: SourceBlock): ObjectEntity {
36
+ return {
37
+ id: createBlockObjectEntityId(block.id),
38
+ kind: "mdx-block",
39
+ label: block.name ? `${block.name} ${block.id}` : block.id,
40
+ blockId: block.id,
41
+ frameKey: block.frameKey,
42
+ chainId: block.chainId,
43
+ pageId: block.frameKey ? createPageObjectEntityId(block.frameKey) : undefined,
44
+ source: {
45
+ path: block.path,
46
+ source: block.source,
47
+ line: block.source?.line,
48
+ column: block.source?.column,
49
+ },
50
+ metadata: {
51
+ blockKind: block.kind ?? null,
52
+ sectionSlug: block.sectionSlug ?? block.chapterSlug ?? null,
53
+ },
54
+ };
55
+ }
@@ -1,4 +1,4 @@
1
- import type { DocumentMeta } from "./types";
1
+ import type { DocumentMeta } from "./documentTypes";
2
2
 
3
3
  export interface ProjectIdentity {
4
4
  name: string;
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  ReaderDocument,
3
3
  SourceBlock,
4
- } from "./types";
4
+ } from "./documentTypes";
5
5
 
6
6
  export const PRESS_TREE_MDX_SOURCE_TYPE = "openpress-press-tree-mdx";
7
7
 
@@ -0,0 +1,57 @@
1
+ // Shape of /openpress/workspace.json — the reader fetches this on
2
+ // boot to decide between gallery routing (multi-Press) and direct
3
+ // load (single Press). One entry per <Press> child of <Workspace>.
4
+ //
5
+ // Single-Press workspaces emit one entry with slug = "" and the
6
+ // legacy /openpress/document.json path. Multi-Press emits one entry
7
+ // per slug; each `documentUrl` resolves to /openpress/<slug>/document.json.
8
+
9
+ export interface WorkspaceManifest {
10
+ version: 1;
11
+ // <Workspace name="..."> prop. Null when the user did not set one.
12
+ // Surfaced as the gallery header in the reader.
13
+ name: string | null;
14
+ presses: WorkspaceManifestPress[];
15
+ }
16
+
17
+ export interface WorkspaceManifestPress {
18
+ // Slug for this Press. Empty string for single-Press workspaces
19
+ // (legacy root); slug-shaped string for multi-Press.
20
+ slug: string;
21
+ // <Press title="..."> prop. Required in v1.0 contract.
22
+ title: string;
23
+ // Page geometry summary. Same shape as the reader's
24
+ // ReaderDocument.theme — readers can show a thumb in the gallery
25
+ // without loading the full document.json.
26
+ page: {
27
+ pagePreset?: string;
28
+ pageLabel?: string;
29
+ pageWidth?: string;
30
+ pageHeight?: string;
31
+ pageAspectRatio?: string;
32
+ pageHeightRatio?: string;
33
+ } | null;
34
+ // Number of pages produced for this Press.
35
+ pageCount: number;
36
+ // Absolute path the reader fetches for this Press's full document.json.
37
+ documentUrl: string;
38
+ }
39
+
40
+ // True when the reader should render the gallery first instead of
41
+ // going straight into a single Press's document.
42
+ export function manifestHasMultiplePresses(manifest: WorkspaceManifest): boolean {
43
+ return manifest.presses.length > 1;
44
+ }
45
+
46
+ // Find a Press entry by slug. Returns null when the slug is unknown.
47
+ export function findManifestPress(
48
+ manifest: WorkspaceManifest,
49
+ slug: string,
50
+ ): WorkspaceManifestPress | null {
51
+ const normalized = slug.replace(/^\/+|\/+$/g, "");
52
+ for (const press of manifest.presses) {
53
+ const normalizedSlug = press.slug.replace(/^\/+|\/+$/g, "");
54
+ if (normalizedSlug === normalized) return press;
55
+ }
56
+ return null;
57
+ }
@@ -9,9 +9,10 @@
9
9
  // document type that wants section flow imports from here; documents that
10
10
  // do not (slides, folios, calendars) skip this module entirely.
11
11
 
12
- import { Fragment, useContext, type ReactNode } from "react";
13
- import { Frame, FrameContext, PressContext, useSource } from "../core";
12
+ import { Fragment, useContext, type ComponentType, type ReactNode } from "react";
13
+ import { Frame, FrameContext, MdxArea, PressContext, useSource } from "../core";
14
14
  import type { MdxAreaOverflow, ResolvedSource } from "../core";
15
+ import { createMdxAreaObjectEntityId } from "../document-model/objectEntityModel";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // <Sections>
@@ -38,11 +39,11 @@ export interface SectionsOpenerProps {
38
39
 
39
40
  export interface SectionsProps {
40
41
  source: string;
41
- page: React.ComponentType<SectionsPageProps>;
42
- opener?: React.ComponentType<SectionsOpenerProps>;
42
+ page?: ComponentType<SectionsPageProps>;
43
+ opener?: ComponentType<SectionsOpenerProps>;
43
44
  }
44
45
 
45
- export function Sections({ source: sourceId, page: Page, opener: Opener }: SectionsProps) {
46
+ export function Sections({ source: sourceId, page: Page = DefaultSectionPage, opener: Opener }: SectionsProps) {
46
47
  const source = useSource(sourceId);
47
48
  const press = useContext(PressContext);
48
49
  const hints = press?.hints ?? null;
@@ -92,6 +93,41 @@ export function Sections({ source: sourceId, page: Page, opener: Opener }: Secti
92
93
  export const Chapters = Sections;
93
94
  export type ChaptersProps = SectionsProps;
94
95
 
96
+ export function DefaultSectionPage({
97
+ frameKey,
98
+ chainId,
99
+ pageIndex,
100
+ totalPages,
101
+ sectionSlug,
102
+ sectionTitle,
103
+ sectionTone,
104
+ }: SectionsPageProps) {
105
+ return (
106
+ <Frame
107
+ frameKey={frameKey}
108
+ role="manuscript.content"
109
+ className="reader-page--content"
110
+ data-page-index={pageIndex}
111
+ data-total-pages={totalPages}
112
+ data-section-id={sectionSlug}
113
+ data-chapter-tone={sectionTone}
114
+ >
115
+ <div className="page-frame">
116
+ <header className="page-header" aria-hidden="true" />
117
+ <main className="page-body">
118
+ <MdxArea chainId={chainId} />
119
+ </main>
120
+ <footer className="page-footer" aria-hidden="true">
121
+ <span className="footer-left">{sectionTitle}</span>
122
+ <span className="footer-right">
123
+ {totalPages > 1 ? `${pageIndex + 1}/${totalPages}` : pageIndex + 1}
124
+ </span>
125
+ </footer>
126
+ </div>
127
+ </Frame>
128
+ );
129
+ }
130
+
95
131
  // ---------------------------------------------------------------------------
96
132
  // <Toc>
97
133
  // ---------------------------------------------------------------------------
@@ -178,15 +214,21 @@ function DefaultTocPage({ frameKey, chainId, pageIndex, totalPages, heading, cla
178
214
 
179
215
  export function TocArea({ chainId, maxLevel, overflow = "extend", className }: TocAreaProps) {
180
216
  const frame = useContext(FrameContext);
181
- const blocks = frame?.consumeArea(chainId) ?? null;
217
+ const consumed = frame?.consumeArea(chainId) ?? null;
218
+ const blocks = consumed?.blocks ?? null;
219
+ const objectId = frame && consumed
220
+ ? createMdxAreaObjectEntityId(frame.frameKey, chainId, consumed.indexInFrame)
221
+ : undefined;
182
222
  return (
183
223
  <div
184
224
  className="openpress-mdx-area openpress-toc-area"
185
225
  data-openpress-mdx-area="true"
186
226
  data-openpress-mdx-area-chain={chainId}
227
+ data-openpress-mdx-area-index={consumed?.indexInFrame}
228
+ data-openpress-object-id={objectId}
187
229
  data-openpress-toc-max-level={maxLevel}
188
230
  data-openpress-mdx-area-overflow={overflow}
189
- data-openpress-mdx-area-empty={blocks == null ? "true" : undefined}
231
+ data-openpress-mdx-area-empty={blocks == null ? "true" : "false"}
190
232
  >
191
233
  <ol className={["toc-list", className].filter(Boolean).join(" ") || undefined}>
192
234
  {blocks}
@@ -27,14 +27,17 @@ export type {
27
27
  SourceNode,
28
28
  } from "../core/types";
29
29
 
30
+ // All presets accept an optional `id` for the 1.0 contract where sources
31
+ // are an array passed via <Press sources>. In v0.x the id came from the
32
+ // record key in `export const sources = { story: mdxSource(...) }`.
30
33
  type MdxSourceOptions =
31
- | { preset: "section-folders"; root?: string }
32
- | { preset: "section-files"; root?: string }
33
- | { preset: "file-list"; files: string[] };
34
+ | { id?: string; preset: "section-folders"; root?: string }
35
+ | { id?: string; preset: "section-files"; root?: string }
36
+ | { id?: string; preset: "file-list"; files: string[] };
34
37
 
35
38
  const VALID_PRESETS = new Set(["section-folders", "section-files", "file-list"]);
36
39
 
37
- export function mdxSource(options: MdxSourceOptions): MdxSourceDescriptor {
40
+ export function mdxSource(options: MdxSourceOptions): MdxSourceDescriptor & { id?: string } {
38
41
  if (!options || typeof options !== "object") {
39
42
  throw new Error("mdxSource() requires an options object.");
40
43
  }
@@ -46,11 +49,15 @@ export function mdxSource(options: MdxSourceOptions): MdxSourceDescriptor {
46
49
  );
47
50
  }
48
51
 
52
+ const id = typeof options.id === "string" && options.id.trim() ? options.id.trim() : undefined;
53
+
49
54
  if (options.preset === "section-folders") {
50
- return normalizeRooted("section-folders", options.root, "chapters") as MdxSourceDescriptorSectionFolders;
55
+ const desc = normalizeRooted("section-folders", options.root, "chapters") as MdxSourceDescriptorSectionFolders;
56
+ return id ? { ...desc, id } : desc;
51
57
  }
52
58
  if (options.preset === "section-files") {
53
- return normalizeRooted("section-files", options.root, "content") as MdxSourceDescriptorSectionFiles;
59
+ const desc = normalizeRooted("section-files", options.root, "content") as MdxSourceDescriptorSectionFiles;
60
+ return id ? { ...desc, id } : desc;
54
61
  }
55
62
 
56
63
  // file-list
@@ -69,7 +76,8 @@ export function mdxSource(options: MdxSourceOptions): MdxSourceDescriptor {
69
76
  if (files.length === 0) {
70
77
  throw new Error('mdxSource({ preset: "file-list" }) requires at least one file.');
71
78
  }
72
- return { type: "mdx", preset: "file-list", files };
79
+ const desc: MdxSourceDescriptor = { type: "mdx", preset: "file-list", files };
80
+ return id ? { ...desc, id } : desc;
73
81
  }
74
82
 
75
83
  function normalizeRooted(
@@ -0,0 +1,168 @@
1
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
2
+ import type { HtmlPageBlock, Theme } from "../document-model";
3
+ import { Panel } from "../shared";
4
+
5
+ // Used by canvas-style Press (slides, social posts) that don't have an
6
+ // MDX-derived TOC. Renders each page as a clickable miniature so the user
7
+ // can navigate without bookmarks. The miniature embeds the same HTML
8
+ // that the main reader renders, scaled to fit the panel width.
9
+
10
+ const FALLBACK_PAGE_WIDTH_PX = 794; // A4 portrait at 96dpi — matches reader default.
11
+
12
+ export function PageThumbnails({
13
+ pages,
14
+ currentPageIndex,
15
+ onSelectPage,
16
+ theme,
17
+ }: {
18
+ pages: HtmlPageBlock[];
19
+ currentPageIndex: number;
20
+ onSelectPage: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
21
+ theme?: Theme;
22
+ }) {
23
+ const pageWidthPx = parsePxLength(theme?.pageWidth) ?? FALLBACK_PAGE_WIDTH_PX;
24
+ const pageHeightPx = parsePxLength(theme?.pageHeight) ?? pageWidthPx;
25
+ // Compute aspect from the parsed dimensions so it always matches the
26
+ // page render. theme.pageAspectRatio may be missing on per-Press
27
+ // documents in multi-Press workspaces, which is why we don't read it
28
+ // here.
29
+ const aspectRatio = `${pageWidthPx} / ${pageHeightPx}`;
30
+
31
+ if (pages.length === 0) {
32
+ return <Panel.Empty className="openpress-asset-empty" role="status">尚無頁面</Panel.Empty>;
33
+ }
34
+
35
+ return (
36
+ <ul className="openpress-thumb-list" aria-label="頁面縮圖">
37
+ {pages.map((page, index) => (
38
+ <li key={page.id}>
39
+ <ThumbnailCard
40
+ page={page}
41
+ index={index}
42
+ active={index === currentPageIndex}
43
+ onClick={() => onSelectPage(index, { behavior: "smooth" })}
44
+ pageWidthPx={pageWidthPx}
45
+ pageHeightPx={pageHeightPx}
46
+ aspectRatio={aspectRatio}
47
+ />
48
+ </li>
49
+ ))}
50
+ </ul>
51
+ );
52
+ }
53
+
54
+ function ThumbnailCard({
55
+ page,
56
+ index,
57
+ active,
58
+ onClick,
59
+ pageWidthPx,
60
+ pageHeightPx,
61
+ aspectRatio,
62
+ }: {
63
+ page: HtmlPageBlock;
64
+ index: number;
65
+ active: boolean;
66
+ onClick: () => void;
67
+ pageWidthPx: number;
68
+ pageHeightPx: number;
69
+ aspectRatio: string;
70
+ }) {
71
+ const surfaceRef = useRef<HTMLDivElement>(null);
72
+ const [scale, setScale] = useState<number | null>(null);
73
+
74
+ useEffect(() => {
75
+ const el = surfaceRef.current;
76
+ if (!el) return;
77
+ const update = () => {
78
+ const w = el.clientWidth;
79
+ const h = el.clientHeight;
80
+ if (w > 0 && h > 0) setScale(Math.min(w / pageWidthPx, h / pageHeightPx));
81
+ };
82
+ update();
83
+ if (typeof ResizeObserver === "undefined") return;
84
+ const ro = new ResizeObserver(update);
85
+ ro.observe(el);
86
+ return () => ro.disconnect();
87
+ }, [pageWidthPx, pageHeightPx]);
88
+
89
+ const className = `openpress-thumb-card${active ? " is-active" : ""}`;
90
+ // Wrap the page HTML using the same class structure as the main
91
+ // reader (`.openpress-html-page > .openpress-html-page__html`) so
92
+ // section-scoped CSS that targets those classes still applies in
93
+ // the miniature.
94
+ const pageClass = page.className
95
+ ? `openpress-html-page ${page.className}`
96
+ : "openpress-html-page";
97
+ const scaledWidth = scale ? pageWidthPx * scale : 0;
98
+ const scaledHeight = scale ? pageHeightPx * scale : 0;
99
+ const frameStyle: CSSProperties = {
100
+ width: `${scaledWidth}px`,
101
+ height: `${scaledHeight}px`,
102
+ position: "relative",
103
+ visibility: scale ? "visible" : "hidden",
104
+ };
105
+ const pageStyle: CSSProperties = {
106
+ "--openpress-page-width": `${pageWidthPx}px`,
107
+ "--openpress-page-height": `${pageHeightPx}px`,
108
+ width: `${pageWidthPx}px`,
109
+ height: `${pageHeightPx}px`,
110
+ transform: scale ? `scale(${scale})` : undefined,
111
+ transformOrigin: "top left",
112
+ position: "absolute",
113
+ top: 0,
114
+ left: 0,
115
+ } as CSSProperties;
116
+ const pageTitle = page.title || `Page ${index + 1}`;
117
+
118
+ return (
119
+ <div
120
+ role="button"
121
+ tabIndex={0}
122
+ className={className}
123
+ data-openpress-thumb-index={index}
124
+ aria-label={`前往第 ${index + 1} 頁:${pageTitle}`}
125
+ aria-current={active ? "page" : undefined}
126
+ onClick={onClick}
127
+ onKeyDown={(event) => {
128
+ if (event.key === "Enter" || event.key === " ") {
129
+ event.preventDefault();
130
+ onClick();
131
+ }
132
+ }}
133
+ >
134
+ <div className="openpress-thumb-card__surface" ref={surfaceRef} style={{ aspectRatio }}>
135
+ <div className="openpress-thumb-card__frame" style={frameStyle}>
136
+ <div className={pageClass} style={pageStyle} data-openpress-thumb-page="true">
137
+ <div
138
+ className="openpress-html-page__html"
139
+ // Page HTML comes from the trusted build pipeline (same source
140
+ // as the main reader).
141
+ dangerouslySetInnerHTML={{ __html: page.html }}
142
+ />
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <div className="openpress-thumb-card__meta">
147
+ <span className="openpress-thumb-card__index">{String(index + 1).padStart(2, "0")}</span>
148
+ <span className="openpress-thumb-card__title">{pageTitle}</span>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function parsePxLength(value: string | undefined): number | null {
155
+ if (!value) return null;
156
+ const match = value.trim().match(/^([\d.]+)\s*(px|mm|cm|in)$/i);
157
+ if (!match) return null;
158
+ const n = Number(match[1]);
159
+ if (!Number.isFinite(n) || n <= 0) return null;
160
+ const unit = match[2].toLowerCase();
161
+ switch (unit) {
162
+ case "px": return n;
163
+ case "mm": return n * (96 / 25.4);
164
+ case "cm": return n * (96 / 2.54);
165
+ case "in": return n * 96;
166
+ default: return null;
167
+ }
168
+ }
@@ -1,30 +1,33 @@
1
1
  import {
2
- useLayoutEffect,
3
2
  useMemo,
4
3
  useRef,
5
- useState,
6
4
  type CSSProperties,
7
5
  type MouseEvent as ReactMouseEvent,
8
6
  type RefCallback,
9
7
  type RefObject,
10
8
  } from "react";
11
9
  import { BookOpen, ExternalLink, X } from "lucide-react";
12
- import { createAnchorPageMap, resolveAnchorPageIndex } from "./anchorMap";
13
- import { collectBookmarkIndex } from "./indexes";
14
- import type { InspectorState } from "./inspector";
15
- import { getProjectIdentity } from "./projectIdentity";
16
- import { useReaderRuntime } from "./readerRuntime";
17
- import { scheduleBrowserFrame } from "./frameScheduler";
18
- import type { DeploymentInfo, ReaderDocument, HtmlPageBlock } from "./types";
19
- import { Bookmarks, CurrentPagePanel } from "./workbenchPanels";
20
- import type { DisplayPage } from "./workbenchTypes";
10
+ import {
11
+ collectBookmarkIndex,
12
+ createAnchorPageMap,
13
+ createPageObjectEntityId,
14
+ getProjectIdentity,
15
+ resolveAnchorPageIndex,
16
+ type DeploymentInfo,
17
+ type HtmlPageBlock,
18
+ type ReaderDocument,
19
+ } from "../document-model";
20
+ import type { InspectorState } from "../workbench/inspector";
21
+ import { useReaderRuntime } from "./useReaderRuntime";
22
+ import { Bookmarks, CurrentPagePanel } from "./ReaderNavigationPanel";
23
+ import type { DisplayPage } from "./readerTypes";
24
+ import { usePageViewportScale } from "./usePageViewportScale";
25
+ import type { PageLayoutMode } from "./pageViewportScaleModel";
21
26
 
22
27
  export const PUBLIC_DRAWER_BREAKPOINT = 1185;
23
- export type ViewMode = "reading" | "paged";
28
+ export type ViewMode = "paged";
24
29
  export type PageInspector = Pick<InspectorState, "enabled" | "handleClick">;
25
30
 
26
- const PAGED_VIEW_MIN_WIDTH = 360;
27
-
28
31
  export function PublicViewer({
29
32
  document,
30
33
  pages,
@@ -46,6 +49,12 @@ export function PublicViewer({
46
49
  pageCount: displayPages.length,
47
50
  rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
48
51
  });
52
+ usePageViewportScale({
53
+ stageRef: reader.stageRef,
54
+ pageContainerRef: sourceContainerRef,
55
+ pageCount: displayPages.length,
56
+ layoutMode: "single",
57
+ });
49
58
  const currentPage = displayPages[reader.currentPageIndex];
50
59
  const staticPdfHref = deploymentInfo.pdf;
51
60
  const projectIdentity = getProjectIdentity(document.meta);
@@ -135,7 +144,7 @@ export function PublicViewer({
135
144
  totalPageLabel={reader.totalPageLabel}
136
145
  progressPercent={reader.progressPercent}
137
146
  title={currentPage?.title || document.meta.title}
138
- pageLabelPrefix={viewMode === "reading" ? "節" : "頁"}
147
+ pageLabelPrefix="頁"
139
148
  showHeading={false}
140
149
  showTitle={false}
141
150
  />
@@ -145,37 +154,8 @@ export function PublicViewer({
145
154
  );
146
155
  }
147
156
 
148
- export function useViewMode() {
149
- const [pagedAllowed, setPagedAllowed] = useState(() => {
150
- if (typeof window === "undefined") return true;
151
- return viewportAllowsPagedMode();
152
- });
153
-
154
- useLayoutEffect(() => {
155
- if (typeof window === "undefined") return undefined;
156
-
157
- let cancelFrame: (() => void) | null = null;
158
- const sync = () => {
159
- cancelFrame?.();
160
- cancelFrame = scheduleBrowserFrame(() => {
161
- cancelFrame = null;
162
- setPagedAllowed(viewportAllowsPagedMode());
163
- });
164
- };
165
-
166
- sync();
167
- window.addEventListener("resize", sync);
168
- window.visualViewport?.addEventListener("resize", sync);
169
- return () => {
170
- window.removeEventListener("resize", sync);
171
- window.visualViewport?.removeEventListener("resize", sync);
172
- cancelFrame?.();
173
- };
174
- }, []);
175
-
176
- const viewMode: ViewMode = pagedAllowed ? "paged" : "reading";
177
-
178
- return { viewMode };
157
+ export function useViewMode(): { viewMode: ViewMode } {
158
+ return { viewMode: "paged" };
179
159
  }
180
160
 
181
161
  export function PrintDocument({
@@ -210,11 +190,6 @@ export function PrintDocument({
210
190
  );
211
191
  }
212
192
 
213
- function viewportAllowsPagedMode() {
214
- if (typeof window === "undefined") return true;
215
- return window.innerWidth >= PAGED_VIEW_MIN_WIDTH;
216
- }
217
-
218
193
  export function PublicPage({
219
194
  pages,
220
195
  currentPageIndex,
@@ -224,6 +199,7 @@ export function PublicPage({
224
199
  exposeSourceData = false,
225
200
  inspector,
226
201
  onInternalAnchorNavigate,
202
+ pageLayoutMode = "single",
227
203
  }: {
228
204
  pages: DisplayPage[];
229
205
  currentPageIndex: number;
@@ -233,6 +209,7 @@ export function PublicPage({
233
209
  exposeSourceData?: boolean;
234
210
  inspector?: PageInspector;
235
211
  onInternalAnchorNavigate?: (anchorId: string, pageIndex?: number) => boolean;
212
+ pageLayoutMode?: PageLayoutMode;
236
213
  }) {
237
214
  const handlePageClick = (event: ReactMouseEvent<HTMLDivElement>) => {
238
215
  if (inspector?.enabled && inspector.handleClick(event)) return;
@@ -257,6 +234,7 @@ export function PublicPage({
257
234
  className="reader-pages openpress-public-page"
258
235
  ref={sourceContainerRef}
259
236
  data-openpress-public-page="true"
237
+ data-openpress-page-layout={pageLayoutMode}
260
238
  onClick={handlePageClick}
261
239
  >
262
240
  {pages.map((page) => (
@@ -265,7 +243,9 @@ export function PublicPage({
265
243
  ref={registerPage(page.pageNumber - 1)}
266
244
  id={`page-${String(page.pageNumber).padStart(2, "0")}`}
267
245
  className="openpress-html-page"
246
+ data-openpress-object-id={page.frameKey ? createPageObjectEntityId(page.frameKey) : undefined}
268
247
  data-openpress-page-index={page.pageNumber - 1}
248
+ data-openpress-page-spread-side={pageLayoutMode === "spread" ? ((page.pageNumber - 1) % 2 === 0 ? "left" : "right") : undefined}
269
249
  data-openpress-active={currentPageIndex === page.pageNumber - 1 ? "true" : "false"}
270
250
  data-source-path={exposeSourceData ? page.source?.path : undefined}
271
251
  data-source-file={exposeSourceData ? page.source?.file : undefined}
@@ -1,5 +1,6 @@
1
1
  import { type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
2
- import type { BookmarkItem } from "./indexes";
2
+ import type { BookmarkItem } from "../document-model";
3
+ import { Panel } from "../shared";
3
4
 
4
5
  type BookmarkSelectOptions = {
5
6
  behavior?: ScrollBehavior;
@@ -20,7 +21,7 @@ export function Bookmarks({
20
21
  };
21
22
 
22
23
  if (items.length === 0) {
23
- return <p className="openpress-asset-empty">尚無書籤</p>;
24
+ return <Panel.Empty className="openpress-asset-empty" role="status">尚無書籤</Panel.Empty>;
24
25
  }
25
26
 
26
27
  return (
@@ -103,8 +104,8 @@ export function CurrentPagePanel({
103
104
  showTitle?: boolean;
104
105
  }) {
105
106
  return (
106
- <section className="openpress-panel-section openpress-panel-section--current" aria-label="目前頁面">
107
- {showHeading ? <div className="openpress-panel-heading">目前頁面</div> : null}
107
+ <Panel.Section className="openpress-panel-section--current" aria-label="目前頁面">
108
+ {showHeading ? <Panel.SectionTitle className="openpress-panel-heading">目前頁面</Panel.SectionTitle> : null}
108
109
  <div className="openpress-current-page-card">
109
110
  <div className="openpress-current-page-card__number" aria-label="目前頁數">
110
111
  {pageLabelPrefix ? <span className="openpress-current-page-card__prefix">{pageLabelPrefix}</span> : null}
@@ -117,6 +118,6 @@ export function CurrentPagePanel({
117
118
  <span style={{ "--progress": `${progressPercent}%` } as CSSProperties} />
118
119
  </div>
119
120
  </div>
120
- </section>
121
+ </Panel.Section>
121
122
  );
122
123
  }
@@ -0,0 +1,11 @@
1
+ export * from "./PageThumbnailsPanel";
2
+ export * from "./PublicReaderPage";
3
+ export * from "./ReaderNavigationPanel";
4
+ export * from "./pageViewportScaleModel";
5
+ export * from "./readerPageRegistry";
6
+ export * from "./readerPageRoute";
7
+ export * from "./readerScroll";
8
+ export * from "./readerStateModel";
9
+ export * from "./readerTypes";
10
+ export * from "./usePageViewportScale";
11
+ export * from "./useReaderRuntime";