@open-press/core 0.8.0 → 1.1.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 (77) hide show
  1. package/README.md +17 -5
  2. package/engine/cli.mjs +9 -9
  3. package/engine/commands/_shared.mjs +70 -18
  4. package/engine/commands/deploy.mjs +3 -3
  5. package/engine/commands/dev.mjs +13 -4
  6. package/engine/commands/image.mjs +29 -0
  7. package/engine/commands/inspect.mjs +3 -2
  8. package/engine/commands/pdf.mjs +2 -2
  9. package/engine/commands/preview.mjs +2 -2
  10. package/engine/commands/render.mjs +6 -4
  11. package/engine/commands/replace.mjs +1 -1
  12. package/engine/commands/search.mjs +1 -1
  13. package/engine/commands/skills-sync.mjs +71 -0
  14. package/engine/commands/typecheck.mjs +71 -1
  15. package/engine/commands/upgrade.mjs +3 -3
  16. package/engine/document-export.mjs +1 -1
  17. package/engine/output/chrome-pdf.mjs +92 -0
  18. package/engine/output/static-server.mjs +60 -17
  19. package/engine/react/comment-marker.mjs +13 -13
  20. package/engine/react/document-entry.mjs +35 -28
  21. package/engine/react/document-export.mjs +309 -170
  22. package/engine/react/mdx-compile.mjs +30 -0
  23. package/engine/react/measurement-css.mjs +21 -0
  24. package/engine/react/object-entities.mjs +85 -0
  25. package/engine/react/pagination/allocator.mjs +48 -3
  26. package/engine/react/pagination.mjs +1 -1
  27. package/engine/react/pipeline/allocate.mjs +31 -65
  28. package/engine/react/pipeline/frame-measurement.mjs +4 -0
  29. package/engine/react/press-tree-inspection.mjs +172 -0
  30. package/engine/react/sources/mdx-resolver.mjs +1 -1
  31. package/engine/react/style-discovery.mjs +22 -4
  32. package/engine/runtime/config.d.mts +8 -0
  33. package/engine/runtime/config.mjs +57 -60
  34. package/engine/runtime/file-utils.mjs +9 -1
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/source-text-tools.mjs +1 -1
  37. package/engine/runtime/source-workspace.mjs +12 -3
  38. package/engine/runtime/validation.mjs +19 -10
  39. package/index.html +4 -0
  40. package/package.json +9 -12
  41. package/src/main.tsx +16 -0
  42. package/src/openpress/app/OpenPressApp.tsx +173 -17
  43. package/src/openpress/app/OpenPressRuntime.tsx +10 -2
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/core/Frame.tsx +20 -7
  46. package/src/openpress/core/FrameContext.tsx +2 -0
  47. package/src/openpress/core/Press.tsx +25 -4
  48. package/src/openpress/core/Workspace.tsx +36 -0
  49. package/src/openpress/core/index.tsx +10 -3
  50. package/src/openpress/core/primitives.tsx +48 -1
  51. package/src/openpress/core/types.ts +86 -41
  52. package/src/openpress/core/useSource.ts +1 -1
  53. package/src/openpress/document-model/documentTypes.ts +9 -0
  54. package/src/openpress/document-model/index.ts +1 -0
  55. package/src/openpress/document-model/objectEntityModel.ts +4 -0
  56. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  57. package/src/openpress/mdx/index.ts +15 -7
  58. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  59. package/src/openpress/reader/index.ts +1 -0
  60. package/src/openpress/workbench/Workbench.tsx +120 -21
  61. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  62. package/src/openpress/workbench/actions/SearchControl.tsx +3 -3
  63. package/src/openpress/workbench/actions/index.ts +1 -0
  64. package/src/openpress/workbench/inspector/useInspectorComments.ts +7 -1
  65. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +5 -1
  66. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +4 -2
  67. package/src/openpress/workbench/project/projectSourceModel.ts +2 -2
  68. package/src/openpress/workbench/workbenchFormatters.ts +2 -2
  69. package/src/styles/openpress/reader-runtime.css +9 -0
  70. package/src/styles/openpress/workbench-panels.css +113 -0
  71. package/src/styles/openpress/workspace-gallery.css +300 -0
  72. package/src/styles/openpress.css +1 -5
  73. package/src/vite-env.d.ts +8 -0
  74. package/tsconfig.json +1 -1
  75. package/vite.config.ts +6 -6
  76. package/engine/commands/init.mjs +0 -24
  77. package/engine/init.mjs +0 -90
@@ -6,11 +6,12 @@
6
6
  // the engine is not allowed to know about higher-level conventions.
7
7
 
8
8
  export { Press, PressContext, PRESS_MARKER } from "./Press";
9
+ export { Workspace, WorkspaceContext, WORKSPACE_MARKER } from "./Workspace";
9
10
  export { Frame, FRAME_MARKER } from "./Frame";
10
11
  export { FrameContext } from "./FrameContext";
11
12
  export { MdxArea } from "./MdxArea";
12
13
  export { useSource } from "./useSource";
13
- export { BaseFigure, BaseCallout, MediaFigure, ImageFigure } from "./primitives";
14
+ export { ObjectEntity, Text, BaseFigure, BaseCallout, MediaFigure, ImageFigure } from "./primitives";
14
15
 
15
16
  export type {
16
17
  FrameProps,
@@ -18,11 +19,16 @@ export type {
18
19
  MdxAreaProps,
19
20
  MdxAreaOverflow,
20
21
  PressProps,
22
+ PageGeometry,
23
+ PressSource,
24
+ WorkspaceProps,
21
25
  BaseFigureProps,
22
26
  MediaFigureProps,
23
27
  BaseCalloutKind,
24
28
  BaseCalloutProps,
25
- Manifest,
29
+ ObjectEntityElement,
30
+ ObjectEntityProps,
31
+ TextProps,
26
32
  // Source-side types are re-exported here for convenience so authors can
27
33
  // import `ResolvedSource` from the same place they import primitives.
28
34
  ResolvedSource,
@@ -36,5 +42,6 @@ export type {
36
42
  FrameAllocation,
37
43
  } from "./types";
38
44
 
39
- export type { PressContextValue, AllocationHints } from "./Press";
45
+ export type { PressContextValue, AllocationHints, PressMetadata } from "./Press";
46
+ export type { WorkspaceContextValue } from "./Workspace";
40
47
  export type { FrameContextValue } from "./FrameContext";
@@ -1,5 +1,52 @@
1
1
  import { cn } from "./cn";
2
- import type { BaseCalloutProps, BaseFigureProps, MediaFigureProps } from "./types";
2
+ import { useContext } from "react";
3
+ import { FrameContext } from "./FrameContext";
4
+ import type { BaseCalloutProps, BaseFigureProps, MediaFigureProps, ObjectEntityProps, TextProps } from "./types";
5
+ import { createScopedObjectEntityId } from "../document-model/objectEntityModel";
6
+
7
+ export function ObjectEntity({
8
+ as: Element = "span",
9
+ objectId,
10
+ kind,
11
+ label,
12
+ parentId,
13
+ pageId,
14
+ blockId,
15
+ frameKey,
16
+ chainId,
17
+ source,
18
+ metadata,
19
+ children,
20
+ ...entityProps
21
+ }: ObjectEntityProps) {
22
+ const frame = useContext(FrameContext);
23
+ const resolvedParentId = parentId ?? frame?.objectId;
24
+ const resolvedPageId = pageId ?? frame?.pageId;
25
+ const resolvedFrameKey = frameKey ?? frame?.frameKey;
26
+ const resolvedObjectId = createScopedObjectEntityId(kind, resolvedParentId, objectId);
27
+
28
+ return (
29
+ <Element
30
+ {...entityProps}
31
+ data-openpress-object-id={resolvedObjectId}
32
+ data-openpress-object-kind={kind}
33
+ data-openpress-object-label={label}
34
+ data-openpress-object-parent-id={resolvedParentId}
35
+ data-openpress-object-page-id={resolvedPageId}
36
+ data-openpress-block-id={blockId}
37
+ data-openpress-object-frame-key={resolvedFrameKey}
38
+ data-openpress-object-chain-id={chainId}
39
+ data-openpress-object-source={source ? JSON.stringify(source) : undefined}
40
+ data-openpress-object-metadata={metadata ? JSON.stringify(metadata) : undefined}
41
+ >
42
+ {children}
43
+ </Element>
44
+ );
45
+ }
46
+
47
+ export function Text(props: TextProps) {
48
+ return <ObjectEntity {...props} kind="text" />;
49
+ }
3
50
 
4
51
  export function BaseFigure({ caption, className, children, ...figureProps }: BaseFigureProps) {
5
52
  return (
@@ -1,4 +1,5 @@
1
1
  import type { HTMLAttributes, ReactNode } from "react";
2
+ import type { EditableSourceRef, ObjectEntityKind } from "../document-model/documentTypes";
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Frame / MdxArea / Press primitives
@@ -25,8 +26,72 @@ export type MdxAreaProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
25
26
  className?: string;
26
27
  };
27
28
 
29
+ // PageGeometry — a custom fixed-size geometry passed to <Press page>.
30
+ // Same shape as the engine's normalized geometry (CSS lengths,
31
+ // matching units between width / height).
32
+ export interface PageGeometry {
33
+ id?: string;
34
+ label?: string;
35
+ preset?: string;
36
+ width: string;
37
+ height: string;
38
+ }
39
+
40
+ // Source descriptor passed inside <Press sources>. The actual type is
41
+ // the return value of mdxSource() from @open-press/core/mdx; we accept
42
+ // "unknown" at the core boundary to avoid a circular type dependency.
43
+ // The engine validates the shape at render time.
44
+ export type PressSource = unknown;
45
+
28
46
  export interface PressProps {
47
+ // Document tree — Frames, manuscript helpers, etc.
29
48
  children: ReactNode;
49
+ // -------------------------------------------------------------------------
50
+ // 1.0 metadata props — optional during v0.x deprecation, required in v1.0.
51
+ // -------------------------------------------------------------------------
52
+ // Document title. Required in 1.0. Used for PDF metadata, HTML <title>,
53
+ // OG tags, and the Workspace gallery / tab-bar label.
54
+ title?: string;
55
+ // Page geometry preset name or a custom geometry object. Optional;
56
+ // workspace default applies if not set.
57
+ page?: "a4" | "social-square" | "slide-16-9" | PageGeometry;
58
+ // Array of source registrations from mdxSource(). Replaces the v0.x
59
+ // `export const sources` named export.
60
+ sources?: ReadonlyArray<PressSource>;
61
+ // URL / output slug for this Press inside a Workspace. Defaults to
62
+ // "/" when only one Press exists in the Workspace; required when the
63
+ // Workspace has multiple Press children.
64
+ slug?: string;
65
+ // Optional per-Press theme directory. Defaults to "./theme" relative
66
+ // to the document file; inherits from <Workspace theme> if not set.
67
+ theme?: string;
68
+ // Optional per-Press components directory. Default "./components".
69
+ componentsDir?: string;
70
+ // Optional caption numbering overrides. Engine defaults to
71
+ // { figure: "Figure", table: "Table", separator: " " }.
72
+ captionNumbering?: {
73
+ figure?: string;
74
+ table?: string;
75
+ separator?: string;
76
+ };
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Workspace — root component holding one or more Press children
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface WorkspaceProps {
84
+ // One or more <Press> children. 1 child = single-doc workspace; N
85
+ // children = multi-doc workspace (proposal + pitch + social, etc).
86
+ children: ReactNode;
87
+ // Project label surfaced in the gallery header, tab bar, and PDF
88
+ // metadata. Optional.
89
+ name?: string;
90
+ // Workspace-level shared theme directory. Press children that don't
91
+ // set their own `theme` prop inherit from this. Default "./theme".
92
+ theme?: string;
93
+ // Workspace-level shared media directory. Default "./media".
94
+ media?: string;
30
95
  }
31
96
 
32
97
  // ---------------------------------------------------------------------------
@@ -53,6 +118,27 @@ export type BaseCalloutProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
53
118
  children: ReactNode;
54
119
  };
55
120
 
121
+ export type ObjectEntityElement = "span" | "div" | "section" | "article" | "figure" | "p";
122
+
123
+ export type ObjectEntityProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
124
+ as?: ObjectEntityElement;
125
+ objectId: string;
126
+ kind: ObjectEntityKind;
127
+ label: string;
128
+ parentId?: string;
129
+ pageId?: string;
130
+ blockId?: string;
131
+ frameKey?: string;
132
+ chainId?: string;
133
+ source?: EditableSourceRef;
134
+ metadata?: Record<string, string | number | boolean | null>;
135
+ children?: ReactNode;
136
+ };
137
+
138
+ export type TextProps = Omit<ObjectEntityProps, "kind"> & {
139
+ as?: "span" | "div" | "p";
140
+ };
141
+
56
142
  // ---------------------------------------------------------------------------
57
143
  // Source descriptors and resolved sources
58
144
  // ---------------------------------------------------------------------------
@@ -148,44 +234,3 @@ export interface ResolvedSource {
148
234
  // MdxArea by area index.
149
235
  export type FrameAllocation = Record<string, Record<string, ReactNode[]>>;
150
236
 
151
- // ---------------------------------------------------------------------------
152
- // Manifest
153
- // ---------------------------------------------------------------------------
154
-
155
- export interface Manifest {
156
- title: string;
157
- subtitle?: string;
158
- organization?: string;
159
- workspaceLabel?: string;
160
- documentDir?: string;
161
- sourceDir?: string;
162
- componentsDir?: string;
163
- mediaDir?: string;
164
- themeDir?: string;
165
- designDoc?: string;
166
- publicDir?: string;
167
- outputDir?: string;
168
- captionNumbering?: {
169
- figure?: string;
170
- table?: string;
171
- separator?: string;
172
- };
173
- pdf?: {
174
- filename?: string;
175
- };
176
- deploy?: {
177
- adapter?: string;
178
- source?: string;
179
- projectName?: string | null;
180
- commitDirty?: boolean;
181
- requiresConfirmation?: boolean;
182
- };
183
- paths?: {
184
- chaptersDir?: string;
185
- sourceDir?: string;
186
- componentsDir?: string;
187
- mediaDir?: string;
188
- themeDir?: string;
189
- designDoc?: string;
190
- };
191
- }
@@ -21,7 +21,7 @@ export function useSource<T extends ResolvedSource = ResolvedSource>(id: string)
21
21
  const knownText = known.length > 0 ? known.join(", ") : "(none)";
22
22
  throw new Error(
23
23
  `Unknown source "${id}". Available sources: ${knownText}. ` +
24
- `Register it under \`export const sources\` in document/index.tsx.`,
24
+ `Register it as a <Press sources={[mdxSource({ id: "${id}", ... })]}> entry in press/index.tsx.`,
25
25
  );
26
26
  }
27
27
  return source as T;
@@ -65,8 +65,12 @@ export interface DocumentMeta {
65
65
  }
66
66
 
67
67
  export interface Theme {
68
+ pagePreset?: string;
69
+ pageLabel?: string;
68
70
  pageWidth?: string;
69
71
  pageHeight?: string;
72
+ pageAspectRatio?: string;
73
+ pageHeightRatio?: string;
70
74
  pagePadding?: string;
71
75
  fontFamily?: string;
72
76
  accentColor?: string;
@@ -108,12 +112,17 @@ export type ObjectEntityKind =
108
112
  | "frame"
109
113
  | "mdx-area"
110
114
  | "mdx-block"
115
+ | "text"
111
116
  | "component"
112
117
  | "media";
113
118
 
114
119
  export interface EditableSourceRef {
115
120
  path: string;
116
121
  file?: string;
122
+ kind?: string;
123
+ objectId?: string;
124
+ scope?: string;
125
+ component?: string;
117
126
  source?: SourceLocation;
118
127
  line?: number;
119
128
  column?: number;
@@ -4,3 +4,4 @@ export * from "./documentTypes";
4
4
  export * from "./objectEntityModel";
5
5
  export * from "./projectIdentityModel";
6
6
  export * from "./reactDocumentMetadataModel";
7
+ export * from "./workspaceManifestModel";
@@ -4,6 +4,10 @@ export function createObjectEntityId(kind: ObjectEntityKind, ...parts: Array<str
4
4
  return [kind, ...parts.map((part) => encodeURIComponent(String(part)))].join(":");
5
5
  }
6
6
 
7
+ export function createScopedObjectEntityId(kind: ObjectEntityKind, parentId: string | undefined, objectId: string) {
8
+ return parentId ? createObjectEntityId(kind, parentId, objectId) : createObjectEntityId(kind, objectId);
9
+ }
10
+
7
11
  export function createBlockObjectEntityId(blockId: string) {
8
12
  return createObjectEntityId("mdx-block", blockId);
9
13
  }
@@ -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
+ }
@@ -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,3 +1,4 @@
1
+ export * from "./PageThumbnailsPanel";
1
2
  export * from "./PublicReaderPage";
2
3
  export * from "./ReaderNavigationPanel";
3
4
  export * from "./pageViewportScaleModel";