@open-press/core 1.2.1 → 1.3.2

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 (45) hide show
  1. package/README.md +2 -2
  2. package/engine/commands/typecheck.mjs +1 -1
  3. package/engine/document-export.mjs +1 -1
  4. package/engine/output/page-block.mjs +11 -2
  5. package/engine/output/public-assets.mjs +41 -6
  6. package/engine/output/static-server.mjs +68 -15
  7. package/engine/react/caption-numbering.mjs +2 -2
  8. package/engine/react/comment-marker.mjs +1 -2
  9. package/engine/react/document-entry.mjs +64 -11
  10. package/engine/react/document-export.d.mts +6 -0
  11. package/engine/react/document-export.mjs +158 -28
  12. package/engine/react/mdx-compile.mjs +4 -4
  13. package/engine/react/measurement-css.mjs +3 -3
  14. package/engine/react/page-folio.mjs +37 -0
  15. package/engine/react/pagination/allocator.mjs +4 -4
  16. package/engine/react/pipeline/frame-measurement.mjs +34 -16
  17. package/engine/react/press-tree-inspection.mjs +43 -13
  18. package/engine/react/project-asset-endpoint.mjs +45 -11
  19. package/engine/react/sources/heading-numbering.mjs +2 -2
  20. package/engine/react/sources/mdx-resolver.mjs +3 -3
  21. package/engine/react/style-discovery.mjs +60 -11
  22. package/engine/react/text-source-transform.mjs +18 -4
  23. package/engine/runtime/config.mjs +22 -22
  24. package/engine/runtime/file-utils.mjs +57 -13
  25. package/engine/runtime/inspection.mjs +40 -15
  26. package/engine/runtime/page-geometry.mjs +6 -6
  27. package/engine/runtime/source-text-tools.mjs +28 -4
  28. package/engine/runtime/source-workspace.mjs +6 -9
  29. package/engine/runtime/validation.mjs +42 -24
  30. package/package.json +1 -1
  31. package/src/openpress/app/OpenPressApp.tsx +20 -18
  32. package/src/openpress/app/OpenPressRuntime.tsx +3 -3
  33. package/src/openpress/app/WorkspaceGalleryPage.tsx +65 -39
  34. package/src/openpress/core/PageFolio.tsx +115 -0
  35. package/src/openpress/core/Press.tsx +5 -10
  36. package/src/openpress/core/Slide.tsx +11 -0
  37. package/src/openpress/core/index.tsx +4 -0
  38. package/src/openpress/core/types.ts +21 -13
  39. package/src/openpress/core/useSource.ts +1 -1
  40. package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
  41. package/src/openpress/reader/SlidePresentationPage.tsx +7 -3
  42. package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +46 -43
  43. package/src/styles/openpress/workbench-toolbar.css +33 -0
  44. package/src/styles/openpress/workspace-gallery.css +130 -47
  45. package/vite.config.ts +82 -16
@@ -451,10 +451,13 @@ function applySourceBlockTableCellEditToText(documentText, {
451
451
  export async function collectSourceTextFiles(config, { scope = "content" } = {}) {
452
452
  const roots = await sourceRoots(config, scope);
453
453
  const files = [];
454
+ const seen = new Set();
454
455
  for (const rootInfo of roots) {
455
456
  const visit = async (absolutePath) => {
457
+ if (seen.has(absolutePath)) return;
456
458
  const extension = path.extname(absolutePath);
457
459
  if (!rootInfo.extensions.has(extension)) return;
460
+ seen.add(absolutePath);
458
461
  const relativePath = path.relative(config.root, absolutePath).replaceAll("\\", "/");
459
462
  files.push({
460
463
  scope: rootInfo.scope,
@@ -557,18 +560,30 @@ async function sourceRoots(config, scope) {
557
560
  const roots = [
558
561
  ...contentRoots,
559
562
  { scope: "design-doc", kind: "file", absolutePath: sourceConfig.paths.designDoc, extensions: MARKDOWN_EXTENSIONS },
560
- { scope: "components", kind: "dir", absolutePath: sourceConfig.paths.componentsDir, extensions: ALL_SOURCE_EXTENSIONS },
561
- { scope: "document-entry", kind: "file", absolutePath: sourceWorkspace.entryPath, extensions: REACT_IMPLEMENTATION_EXTENSIONS },
562
- ...implementationRoots(sourceWorkspace),
563
+ ...await implementationRoots(sourceWorkspace),
563
564
  ];
564
565
  return roots;
565
566
  }
566
567
  return contentRoots;
567
568
  }
568
569
 
569
- function implementationRoots(sourceWorkspace) {
570
+ async function implementationRoots(sourceWorkspace) {
570
571
  const roots = [];
571
572
  const seen = new Set();
573
+ const documentRoot = sourceWorkspace.config.paths.documentRoot;
574
+ const entries = await readDirectoryEntries(documentRoot);
575
+ for (const entry of entries) {
576
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
577
+ const absolutePath = path.join(documentRoot, entry.name);
578
+ if (seen.has(absolutePath)) continue;
579
+ seen.add(absolutePath);
580
+ roots.push({
581
+ scope: entry.name === "shared" ? "shared-source" : "press-source",
582
+ kind: "dir",
583
+ absolutePath,
584
+ extensions: ALL_SOURCE_EXTENSIONS,
585
+ });
586
+ }
572
587
  for (const root of sourceWorkspace.contentRoots ?? [{ kind: "dir", absolutePath: sourceWorkspace.sourceDir }]) {
573
588
  const absolutePath = root.kind === "dir" ? root.absolutePath : path.dirname(root.absolutePath);
574
589
  if (seen.has(absolutePath)) continue;
@@ -583,6 +598,15 @@ function implementationRoots(sourceWorkspace) {
583
598
  return roots;
584
599
  }
585
600
 
601
+ async function readDirectoryEntries(directory) {
602
+ try {
603
+ return await fs.readdir(directory, { withFileTypes: true });
604
+ } catch (error) {
605
+ if (error?.code === "ENOENT") return [];
606
+ throw error;
607
+ }
608
+ }
609
+
586
610
  function forEachLine(text, visit) {
587
611
  const lineRe = /([^\r\n]*)(\r\n|\n|\r|$)/g;
588
612
  let lineNumber = 1;
@@ -12,7 +12,7 @@ export async function resolveActiveSourceWorkspace(config) {
12
12
  const reactEntry = await loadReactDocumentEntry(config.root);
13
13
  if (!reactEntry) {
14
14
  throw new Error(
15
- "React/MDX document entry not found. Expected press/index.tsx with a Press default export before using workspace source tools.",
15
+ "React/MDX document entry not found. Expected one or more press/*/press.tsx files before using workspace source tools.",
16
16
  );
17
17
  }
18
18
  // Aggregate sources across every Press in the Workspace. Workspace
@@ -26,6 +26,7 @@ export async function resolveActiveSourceWorkspace(config) {
26
26
  }
27
27
  const contentRoots = contentRootsFromSources(aggregateSources, reactEntry.config);
28
28
  const sourceDir = firstDirectoryRoot(contentRoots) ?? reactEntry.config.paths.documentRoot;
29
+ const hasRegisteredSources = contentRoots.length > 0;
29
30
 
30
31
  return {
31
32
  kind: "react-mdx",
@@ -34,11 +35,12 @@ export async function resolveActiveSourceWorkspace(config) {
34
35
  entryPath: reactEntry.entryPath,
35
36
  sourceDir,
36
37
  contentRoots,
38
+ hasRegisteredSources,
37
39
  contentExtensions: REACT_MDX_CONTENT_EXTENSIONS,
38
40
  contentLabel: "React MDX chapter source",
39
41
  missingCode: "react-source.missing",
40
42
  emptyCode: "react-source.empty",
41
- missingMessage: "Registered React MDX sources do not exist yet; create the files or roots declared in press/index.tsx `sources` before running export.",
43
+ missingMessage: "Registered React MDX sources do not exist yet; create the files or roots declared in press/*/press.tsx `sources` before running export.",
42
44
  emptyMessage: "Registered React MDX sources contain no `*.mdx` files; the document will export with zero source blocks.",
43
45
  };
44
46
  }
@@ -78,6 +80,7 @@ export async function collectActiveContentFiles(sourceWorkspace, { skipUnderscor
78
80
 
79
81
  export async function sourceDirectoryExists(sourceWorkspace) {
80
82
  const roots = sourceWorkspace.contentRoots ?? [{ kind: "dir", absolutePath: sourceWorkspace.sourceDir }];
83
+ if (roots.length === 0) return true;
81
84
  for (const root of roots) {
82
85
  try {
83
86
  const stat = await fs.stat(root.absolutePath);
@@ -93,13 +96,7 @@ export async function sourceDirectoryExists(sourceWorkspace) {
93
96
  function contentRootsFromSources(sources, config) {
94
97
  const entries = Object.entries(sources ?? {});
95
98
  if (entries.length === 0) {
96
- return [{
97
- kind: "dir",
98
- absolutePath: config.paths.sourceDir,
99
- basePath: config.paths.sourceDir,
100
- sourceId: "default",
101
- preset: "section-folders",
102
- }];
99
+ return [];
103
100
  }
104
101
 
105
102
  const roots = [];
@@ -17,12 +17,20 @@ const PUBLIC_DEPLOY_ADAPTERS = new Set([
17
17
  "vercel",
18
18
  ]);
19
19
 
20
- // A directory is an OpenPress workspace if it contains a
21
- // press/index.tsx entry, or a package.json with an "openpress" field.
20
+ // A directory is an OpenPress workspace if it contains folder-convention
21
+ // Press entries, or a package.json with an "openpress" field.
22
22
  async function isWorkspaceRoot(dir) {
23
23
  try {
24
- await fs.access(path.join(dir, "press", "index.tsx"));
25
- return true;
24
+ const pressEntries = await fs.readdir(path.join(dir, "press"), { withFileTypes: true });
25
+ if (pressEntries.some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "shared")) {
26
+ for (const entry of pressEntries) {
27
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "shared") continue;
28
+ try {
29
+ await fs.access(path.join(dir, "press", entry.name, "press.tsx"));
30
+ return true;
31
+ } catch {}
32
+ }
33
+ }
26
34
  } catch {}
27
35
  try {
28
36
  const pkg = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8"));
@@ -60,11 +68,7 @@ export async function validateWorkspace(root) {
60
68
 
61
69
  mark("config");
62
70
  for (const [key, target] of [
63
- ["sourceDir", sourceWorkspace.sourceDir],
64
- ["mediaDir", activeConfig.paths.mediaDir],
65
- ["themeDir", activeConfig.paths.themeDir],
66
71
  ["designDoc", activeConfig.paths.designDoc],
67
- ["componentsDir", activeConfig.paths.componentsDir],
68
72
  ]) {
69
73
  if (!(await exists(target))) add("error", `config.${key}`, `Configured OpenPress path \`${key}\` does not exist.`, target);
70
74
  }
@@ -87,11 +91,11 @@ export async function validateWorkspace(root) {
87
91
 
88
92
  mark(sourceWorkspace.checkedName);
89
93
  if (!(typeof activeConfig.title === "string" && activeConfig.title.trim())) {
90
- add("warning", "press.title", "<Press title> is missing in press/index.tsx; the workbench will show the default placeholder.", activeConfig.configPath);
94
+ add("warning", "press.title", "<Press title> is missing in press/*/press.tsx; the workbench will show the default placeholder.", activeConfig.configPath);
91
95
  }
92
- if (!(await sourceDirectoryExists(sourceWorkspace))) {
96
+ if (sourceWorkspace.hasRegisteredSources && !(await sourceDirectoryExists(sourceWorkspace))) {
93
97
  add("warning", sourceWorkspace.missingCode, sourceWorkspace.missingMessage, sourceWorkspace.sourceDir);
94
- } else {
98
+ } else if (sourceWorkspace.hasRegisteredSources) {
95
99
  const contentFiles = await collectActiveContentFiles(sourceWorkspace, { skipUnderscoreFiles: true });
96
100
  if (contentFiles.length === 0) {
97
101
  add("warning", sourceWorkspace.emptyCode, sourceWorkspace.emptyMessage, sourceWorkspace.sourceDir);
@@ -119,19 +123,19 @@ export async function validateWorkspace(root) {
119
123
  }
120
124
 
121
125
  mark("react-source");
122
- const documentJsonPath = path.join(activeConfig.paths.publicDir, "document.json");
123
- const exportedDocument = await readJsonIfExists(documentJsonPath);
124
- const pressWarnings = exportedDocument?.source?.warnings;
125
- if (Array.isArray(pressWarnings)) {
126
- for (const warning of pressWarnings) {
127
- const code = typeof warning?.code === "string" && warning.code ? warning.code : "warning";
128
- add(
129
- "warning",
130
- `react-source.${code}`,
131
- pressWarningMessage(warning),
132
- documentJsonPath,
133
- warning,
134
- );
126
+ for (const exported of await readExportedPressDocuments(activeConfig.paths.publicDir)) {
127
+ const pressWarnings = exported.document?.source?.warnings;
128
+ if (Array.isArray(pressWarnings)) {
129
+ for (const warning of pressWarnings) {
130
+ const code = typeof warning?.code === "string" && warning.code ? warning.code : "warning";
131
+ add(
132
+ "warning",
133
+ `react-source.${code}`,
134
+ pressWarningMessage(warning),
135
+ exported.path,
136
+ warning,
137
+ );
138
+ }
135
139
  }
136
140
  }
137
141
 
@@ -173,6 +177,20 @@ async function readJsonIfExists(filePath) {
173
177
  }
174
178
  }
175
179
 
180
+ async function readExportedPressDocuments(publicDir) {
181
+ const manifestPath = path.join(publicDir, "workspace.json");
182
+ const manifest = await readJsonIfExists(manifestPath);
183
+ if (!Array.isArray(manifest?.presses)) return [];
184
+ const out = [];
185
+ for (const press of manifest.presses) {
186
+ if (typeof press?.slug !== "string" || !press.slug.trim()) continue;
187
+ const documentJsonPath = path.join(publicDir, press.slug.trim(), "document.json");
188
+ const document = await readJsonIfExists(documentJsonPath);
189
+ if (document) out.push({ path: documentJsonPath, document });
190
+ }
191
+ return out;
192
+ }
193
+
176
194
  async function exists(filePath) {
177
195
  try {
178
196
  await fs.access(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.2.1",
3
+ "version": "1.3.2",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",
@@ -24,9 +24,7 @@ type LoadState =
24
24
  document: ReaderDocument;
25
25
  deploymentInfo: DeploymentInfo;
26
26
  manifest: WorkspaceManifest | null;
27
- // Empty string for single-Press workspaces (no slug routing needed)
28
- // or for the root entry of a multi-Press workspace. Otherwise the
29
- // active press's slug — used by refresh/back/forward to re-resolve.
27
+ // Active Press slug, used by refresh/back/forward to re-resolve.
30
28
  activeSlug: string;
31
29
  runtimeMode: OpenPressRuntimeMode;
32
30
  }
@@ -75,16 +73,10 @@ export function OpenPressApp() {
75
73
  route: WorkspaceRoute,
76
74
  deploymentInfo: DeploymentInfo,
77
75
  ) => {
78
- // No manifest (legacy deploy): always load /openpress/document.json.
79
76
  if (!manifest || manifest.presses.length === 0) {
80
- const document = await loadReaderDocument("/openpress/document.json");
81
77
  setState({
82
- status: "ready",
83
- document,
84
- deploymentInfo,
85
- manifest,
86
- activeSlug: "",
87
- runtimeMode: resolveRuntimeMode(document, route.mode),
78
+ status: "error",
79
+ message: "OpenPress workspace manifest is missing or empty. Run open-press render to generate /openpress/workspace.json.",
88
80
  });
89
81
  return;
90
82
  }
@@ -124,8 +116,8 @@ export function OpenPressApp() {
124
116
  const press = state.manifest
125
117
  ? findManifestPress(state.manifest, state.activeSlug)
126
118
  : null;
127
- const url = press?.documentUrl ?? "/openpress/document.json";
128
- const document = await loadReaderDocument(url);
119
+ if (!press) return;
120
+ const document = await loadReaderDocument(press.documentUrl);
129
121
  setState((latest) => {
130
122
  if (latest.status !== "ready") return latest;
131
123
  return { ...latest, document };
@@ -133,8 +125,7 @@ export function OpenPressApp() {
133
125
  }, [state]);
134
126
 
135
127
  // Gallery click → pushState + load. Bypasses resolveFromRoute's
136
- // "empty slug + multi-Press → gallery" branch: an explicit click on
137
- // the unslugged root Press must enter it, not bounce back to gallery.
128
+ // "empty slug + multi-Press → gallery" branch.
138
129
  const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
139
130
  if (state.status !== "gallery") return;
140
131
  pushPressRoute(press.slug, "preview");
@@ -230,16 +221,27 @@ export function OpenPressApp() {
230
221
  const presentationSlug = state.activeSlug || currentRouteFromLocation().slug;
231
222
  const openPresentation = state.document.meta.type === "slides" && presentationSlug
232
223
  ? (pageIndex: number) => {
224
+ // requestFullscreen must be called synchronously within the user gesture.
225
+ // window.open() creates a new browsing context, so the user gesture is lost
226
+ // and the browser blocks fullscreen. Navigate in-place instead.
227
+ const root = globalThis.document?.documentElement;
228
+ if (root?.requestFullscreen) void root.requestFullscreen().catch(() => {});
233
229
  const slug = normalizeSlug(presentationSlug);
234
- const pathname = slug ? `/${slug}` : "/";
235
- const hash = `#page-${String(pageIndex + 1).padStart(2, "0")}`;
236
- window.open(`${pathname}?fullscreen=1${hash}`, "_blank", "noopener,noreferrer");
230
+ pushPressRoute(slug, "present", pageIndex);
231
+ setState((latest) => latest.status === "ready"
232
+ ? { ...latest, runtimeMode: "present" }
233
+ : latest);
237
234
  }
238
235
  : undefined;
239
236
 
240
237
  const exitPresentation = state.document.meta.type === "slides"
241
238
  ? (pageIndex: number) => {
242
239
  if (state.status !== "ready") return;
240
+ // Exit fullscreen before returning to the workbench.
241
+ const activeDoc = globalThis.document;
242
+ if (activeDoc?.fullscreenElement && activeDoc?.exitFullscreen) {
243
+ void activeDoc.exitFullscreen().catch(() => {});
244
+ }
243
245
  const slug = state.activeSlug || currentRouteFromLocation().slug;
244
246
  if (slug) pushPressRoute(slug, "preview", pageIndex);
245
247
  setState((latest) => latest.status === "ready"
@@ -48,7 +48,7 @@ export function OpenPressRuntime({
48
48
  // (e.g. /<slug>/present -> /<slug>/preview after exiting the slide
49
49
  // presenter), so the SlidePresentationPage exits to the wrong
50
50
  // route-driven branch (PublicViewer instead of HtmlWorkbench) and the
51
- // user sees the legacy public-viewer chrome until a hard reload.
51
+ // user sees stale public-viewer chrome until a hard reload.
52
52
  // Bump a version on every pathname/search change so the memos
53
53
  // re-evaluate exactly when the URL does.
54
54
  const routeVersion = useLocationVersion();
@@ -125,11 +125,11 @@ function EmptyState({ style, workspaceMode }: { style: CSSProperties; workspaceM
125
125
  <p className="openpress-empty-state__eyebrow">OpenPress</p>
126
126
  <h1 className="openpress-empty-state__title">This document has no content yet.</h1>
127
127
  <p className="openpress-empty-state__body">
128
- Add React MDX chapter files under <code>press/chapters/**/content/</code>, then re-build.
128
+ Add React MDX chapter files under <code>press/&lt;slug&gt;/chapters/**/content/</code>, then re-build.
129
129
  </p>
130
130
  {workspaceMode ? (
131
131
  <ol className="openpress-empty-state__steps">
132
- <li><code>npm run build</code> &nbsp;— validates and refreshes <code>public/openpress/document.json</code></li>
132
+ <li><code>npm run build</code> &nbsp;— validates and refreshes <code>public/openpress/workspace.json</code></li>
133
133
  <li>Reload this page</li>
134
134
  </ol>
135
135
  ) : (
@@ -1,49 +1,90 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
2
2
  import type { HtmlPageBlock, ReaderDocument, WorkspaceManifest, WorkspaceManifestPress } from "../document-model";
3
3
 
4
+ type GalleryFilter = "all" | "pages" | "slides";
5
+
4
6
  interface Props {
5
7
  manifest: WorkspaceManifest;
6
- // Called when the reader navigates into a specific Press. The host
7
- // is responsible for routing (history.pushState, hash, etc.); the
8
- // gallery just emits the chosen slug.
9
8
  onSelectPress: (press: WorkspaceManifestPress) => void;
10
9
  }
11
10
 
12
- // Reader landing page for multi-Press workspaces. Shows a Figma-style
13
- // uniform-grid card per Press; each card lazily loads that Press's
14
- // document.json and renders the first page as a thumbnail preview.
15
- // Single-Press workspaces skip the gallery entirely.
16
11
  export function WorkspaceGalleryPage({ manifest, onSelectPress }: Props) {
17
12
  const heading = manifest.name ?? "Workspace";
18
- const pressCount = String(manifest.presses.length).padStart(2, "0");
13
+ const [filter, setFilter] = useState<GalleryFilter>("all");
14
+
15
+ const counts = {
16
+ all: manifest.presses.length,
17
+ pages: manifest.presses.filter((p) => p.type === "pages").length,
18
+ slides: manifest.presses.filter((p) => p.type === "slides").length,
19
+ };
20
+
21
+ const visiblePresses = filter === "all"
22
+ ? manifest.presses
23
+ : manifest.presses.filter((p) => p.type === filter);
19
24
 
20
25
  return (
21
26
  <main className="openpress-workspace-gallery" aria-labelledby="workspace-gallery-heading">
22
27
  <header className="openpress-workspace-gallery__header">
23
28
  <div className="openpress-workspace-gallery__headline">
24
- <p className="openpress-workspace-gallery__eyebrow">Workspace</p>
29
+ <p className="openpress-workspace-gallery__brand">
30
+ <span className="openpress-workspace-gallery__brand-mark">open-press</span>
31
+ <span className="openpress-workspace-gallery__brand-sep" aria-hidden="true">/</span>
32
+ <span className="openpress-workspace-gallery__eyebrow">Workspace</span>
33
+ </p>
25
34
  <h1 id="workspace-gallery-heading">{heading}</h1>
26
35
  </div>
27
- <p className="openpress-workspace-gallery__count">
28
- <span>{pressCount}</span>
29
- <small>{manifest.presses.length === 1 ? "document" : "documents"}</small>
30
- </p>
31
36
  </header>
32
37
 
33
- <ul className="openpress-workspace-gallery__grid" role="list">
34
- {manifest.presses.map((press) => (
35
- <li key={press.slug || "root"} className="openpress-workspace-gallery__item">
36
- <PressCard press={press} onSelect={() => onSelectPress(press)} />
37
- </li>
38
- ))}
39
- </ul>
38
+ <div className="openpress-workspace-gallery__body">
39
+ <nav className="openpress-workspace-gallery__sidebar" aria-label="文件類型篩選">
40
+ <FilterButton label="All" count={counts.all} active={filter === "all"} onClick={() => setFilter("all")} />
41
+ <FilterButton label="Pages" count={counts.pages} active={filter === "pages"} onClick={() => setFilter("pages")} />
42
+ <FilterButton label="Slides" count={counts.slides} active={filter === "slides"} onClick={() => setFilter("slides")} />
43
+ </nav>
44
+
45
+ <section className="openpress-workspace-gallery__main" aria-label={`${filter} 文件`}>
46
+ {visiblePresses.length > 0 ? (
47
+ <ul className="openpress-workspace-gallery__grid" role="list">
48
+ {visiblePresses.map((press) => (
49
+ <li key={press.slug || "root"} className="openpress-workspace-gallery__item">
50
+ <PressCard press={press} onSelect={() => onSelectPress(press)} />
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ ) : (
55
+ <p className="openpress-workspace-gallery__empty">No {filter} documents.</p>
56
+ )}
57
+ </section>
58
+ </div>
40
59
  </main>
41
60
  );
42
61
  }
43
62
 
44
- // Card is a div+role=button (not <button>) so it can contain the
45
- // rendered page HTML — buttons may only hold phrasing content, and
46
- // page HTML is block-level.
63
+ function FilterButton({
64
+ label,
65
+ count,
66
+ active,
67
+ onClick,
68
+ }: {
69
+ label: string;
70
+ count: number;
71
+ active: boolean;
72
+ onClick: () => void;
73
+ }) {
74
+ return (
75
+ <button
76
+ type="button"
77
+ className="openpress-workspace-gallery__filter-btn"
78
+ aria-pressed={active}
79
+ data-active={active ? "true" : "false"}
80
+ onClick={onClick}
81
+ >
82
+ <span className="openpress-workspace-gallery__filter-label">{label}</span>
83
+ <span className="openpress-workspace-gallery__filter-count">{String(count).padStart(2, "0")}</span>
84
+ </button>
85
+ );
86
+ }
87
+
47
88
  function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelect: () => void }) {
48
89
  const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {
49
90
  if (event.key === "Enter" || event.key === " ") {
@@ -62,7 +103,7 @@ function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelec
62
103
  aria-label={`Open ${press.title}`}
63
104
  >
64
105
  <PressThumbnail press={press} />
65
- <div className="openpress-workspace-gallery__body">
106
+ <div className="openpress-workspace-gallery__card-body">
66
107
  <div className="openpress-workspace-gallery__title">{press.title}</div>
67
108
  <div className="openpress-workspace-gallery__meta">
68
109
  {press.slug ? <span className="openpress-workspace-gallery__slug">{press.slug}</span> : null}
@@ -78,9 +119,6 @@ function PressCard({ press, onSelect }: { press: WorkspaceManifestPress; onSelec
78
119
  function PressThumbnail({ press }: { press: WorkspaceManifestPress }) {
79
120
  const [state, setState] = useState<ThumbnailState>({ status: "loading" });
80
121
 
81
- // Lazy-load each Press's document.json so the gallery doesn't block
82
- // on a network waterfall when there are many Press. Errors degrade
83
- // to the geometry-only placeholder used by the loading state.
84
122
  useEffect(() => {
85
123
  let cancelled = false;
86
124
  fetchFirstPage(press.documentUrl).then((page) => {
@@ -92,9 +130,6 @@ function PressThumbnail({ press }: { press: WorkspaceManifestPress }) {
92
130
  return () => { cancelled = true; };
93
131
  }, [press.documentUrl]);
94
132
 
95
- // Outer card is uniform 4:3 (set in CSS). The page itself letterboxes
96
- // inside via centered scale, so A4 portrait renders tall-and-narrow,
97
- // social square renders centered, 16:9 slide stretches edge-to-edge.
98
133
  return (
99
134
  <div className="openpress-workspace-gallery__thumb" aria-hidden="true">
100
135
  {state.status === "ready" ? (
@@ -147,10 +182,6 @@ function PageMiniature({ page, press }: { page: HtmlPageBlock; press: WorkspaceM
147
182
  visibility: scale ? "visible" : "hidden",
148
183
  };
149
184
 
150
- // Match the wrapping used by PublicReaderPage so scoped CSS targeting
151
- // `.openpress-html-page__html` selectors lights up identically. The
152
- // outer frame owns centering; the page only scales from its top-left
153
- // origin, which avoids mixed translate/scale centering drift.
154
185
  const pageStyle: CSSProperties = {
155
186
  "--openpress-page-width": `${pageWidthPx}px`,
156
187
  "--openpress-page-height": `${pageHeightPx}px`,
@@ -172,7 +203,6 @@ function PageMiniature({ page, press }: { page: HtmlPageBlock; press: WorkspaceM
172
203
  <div className={pageClass} style={pageStyle} data-openpress-thumb-page="true">
173
204
  <div
174
205
  className="openpress-html-page__html"
175
- // Trusted HTML — same source as the reader's main render path.
176
206
  dangerouslySetInnerHTML={{ __html: page.html }}
177
207
  />
178
208
  </div>
@@ -198,10 +228,6 @@ async function fetchFirstPage(url: string): Promise<HtmlPageBlock | null> {
198
228
  }
199
229
  }
200
230
 
201
- // Convert a CSS length string (px / mm / cm / in) into device pixels
202
- // at 96 dpi. A4 pages are stored as "210mm" / "297mm" so the gallery
203
- // and thumbnail scalers need this to compute their fit ratio — using
204
- // the bare string would always fall back to the default fallback.
205
231
  function parsePxLength(value: string | undefined): number | null {
206
232
  if (!value) return null;
207
233
  const match = value.trim().match(/^([\d.]+)\s*(px|mm|cm|in)$/i);
@@ -0,0 +1,115 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cn } from "./cn";
3
+
4
+ export type PageFolioVariant = "current" | "total" | "slash" | "of" | "prefix";
5
+ export type PageFolioNumberFormat = "plain" | "2-digit" | "3-digit";
6
+
7
+ export type PageFolioProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
8
+ variant?: PageFolioVariant;
9
+ currentFormat?: PageFolioNumberFormat;
10
+ totalFormat?: PageFolioNumberFormat;
11
+ prefix?: string;
12
+ separator?: string;
13
+ ofLabel?: string;
14
+ ariaLabel?: string;
15
+ };
16
+
17
+ export function PageFolio({
18
+ variant = "current",
19
+ currentFormat = "plain",
20
+ totalFormat = "plain",
21
+ prefix = "",
22
+ separator = "/",
23
+ ofLabel = "of",
24
+ ariaLabel,
25
+ className,
26
+ ...rest
27
+ }: PageFolioProps) {
28
+ const current = placeholderForFormat(currentFormat);
29
+ const total = placeholderForFormat(totalFormat);
30
+ const label = ariaLabel ?? defaultAriaLabel(variant);
31
+
32
+ return (
33
+ <span
34
+ {...rest}
35
+ className={cn("openpress-page-folio", `openpress-page-folio--${variant}`, className)}
36
+ data-openpress-page-folio="true"
37
+ data-openpress-page-folio-variant={variant}
38
+ data-openpress-page-folio-current-format={currentFormat}
39
+ data-openpress-page-folio-total-format={totalFormat}
40
+ data-openpress-page-folio-prefix={prefix}
41
+ data-openpress-page-folio-separator={separator}
42
+ data-openpress-page-folio-of-label={ofLabel}
43
+ aria-label={label}
44
+ >
45
+ {renderFolioParts({ variant, current, total, currentFormat, totalFormat, prefix, separator, ofLabel })}
46
+ </span>
47
+ );
48
+ }
49
+
50
+ function renderFolioParts({
51
+ variant,
52
+ current,
53
+ total,
54
+ currentFormat,
55
+ totalFormat,
56
+ prefix,
57
+ separator,
58
+ ofLabel,
59
+ }: {
60
+ variant: PageFolioVariant;
61
+ current: string;
62
+ total: string;
63
+ currentFormat: PageFolioNumberFormat;
64
+ totalFormat: PageFolioNumberFormat;
65
+ prefix: string;
66
+ separator: string;
67
+ ofLabel: string;
68
+ }) {
69
+ if (variant === "total") {
70
+ return <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>;
71
+ }
72
+
73
+ if (variant === "slash") {
74
+ return (
75
+ <>
76
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
77
+ <span className="openpress-page-folio__separator" data-openpress-page-folio-separator-text="true">{separator}</span>
78
+ <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>
79
+ </>
80
+ );
81
+ }
82
+
83
+ if (variant === "of") {
84
+ return (
85
+ <>
86
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
87
+ <span className="openpress-page-folio__separator" data-openpress-page-folio-of-text="true">{ofLabel}</span>
88
+ <span className="openpress-page-folio__total" data-openpress-page-folio-total="true" data-openpress-page-folio-format={totalFormat}>{total}</span>
89
+ </>
90
+ );
91
+ }
92
+
93
+ if (variant === "prefix") {
94
+ return (
95
+ <>
96
+ <span className="openpress-page-folio__prefix" data-openpress-page-folio-prefix-text="true">{prefix}</span>
97
+ <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>
98
+ </>
99
+ );
100
+ }
101
+
102
+ return <span className="openpress-page-folio__current" data-openpress-page-folio-current="true" data-openpress-page-folio-format={currentFormat}>{current}</span>;
103
+ }
104
+
105
+ function placeholderForFormat(format: PageFolioNumberFormat) {
106
+ if (format === "3-digit") return "000";
107
+ if (format === "2-digit") return "00";
108
+ return "0";
109
+ }
110
+
111
+ function defaultAriaLabel(variant: PageFolioVariant) {
112
+ if (variant === "total") return "Total pages";
113
+ if (variant === "slash" || variant === "of") return "Page number and total pages";
114
+ return "Page number";
115
+ }