@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.
- package/README.md +2 -2
- package/engine/commands/typecheck.mjs +1 -1
- package/engine/document-export.mjs +1 -1
- package/engine/output/page-block.mjs +11 -2
- package/engine/output/public-assets.mjs +41 -6
- package/engine/output/static-server.mjs +68 -15
- package/engine/react/caption-numbering.mjs +2 -2
- package/engine/react/comment-marker.mjs +1 -2
- package/engine/react/document-entry.mjs +64 -11
- package/engine/react/document-export.d.mts +6 -0
- package/engine/react/document-export.mjs +158 -28
- package/engine/react/mdx-compile.mjs +4 -4
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/page-folio.mjs +37 -0
- package/engine/react/pagination/allocator.mjs +4 -4
- package/engine/react/pipeline/frame-measurement.mjs +34 -16
- package/engine/react/press-tree-inspection.mjs +43 -13
- package/engine/react/project-asset-endpoint.mjs +45 -11
- package/engine/react/sources/heading-numbering.mjs +2 -2
- package/engine/react/sources/mdx-resolver.mjs +3 -3
- package/engine/react/style-discovery.mjs +60 -11
- package/engine/react/text-source-transform.mjs +18 -4
- package/engine/runtime/config.mjs +22 -22
- package/engine/runtime/file-utils.mjs +57 -13
- package/engine/runtime/inspection.mjs +40 -15
- package/engine/runtime/page-geometry.mjs +6 -6
- package/engine/runtime/source-text-tools.mjs +28 -4
- package/engine/runtime/source-workspace.mjs +6 -9
- package/engine/runtime/validation.mjs +42 -24
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +20 -18
- package/src/openpress/app/OpenPressRuntime.tsx +3 -3
- package/src/openpress/app/WorkspaceGalleryPage.tsx +65 -39
- package/src/openpress/core/PageFolio.tsx +115 -0
- package/src/openpress/core/Press.tsx +5 -10
- package/src/openpress/core/Slide.tsx +11 -0
- package/src/openpress/core/index.tsx +4 -0
- package/src/openpress/core/types.ts +21 -13
- package/src/openpress/core/useSource.ts +1 -1
- package/src/openpress/document-model/workspaceManifestModel.ts +4 -9
- package/src/openpress/reader/SlidePresentationPage.tsx +7 -3
- package/src/openpress/workbench/shell/WorkbenchToolbarActions.tsx +46 -43
- package/src/styles/openpress/workbench-toolbar.css +33 -0
- package/src/styles/openpress/workspace-gallery.css +130 -47
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
21
|
-
//
|
|
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.
|
|
25
|
-
|
|
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
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
@@ -24,9 +24,7 @@ type LoadState =
|
|
|
24
24
|
document: ReaderDocument;
|
|
25
25
|
deploymentInfo: DeploymentInfo;
|
|
26
26
|
manifest: WorkspaceManifest | null;
|
|
27
|
-
//
|
|
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: "
|
|
83
|
-
|
|
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
|
-
|
|
128
|
-
const document = await loadReaderDocument(
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
|
128
|
+
Add React MDX chapter files under <code>press/<slug>/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> — validates and refreshes <code>public/openpress/
|
|
132
|
+
<li><code>npm run build</code> — 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
|
|
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-
|
|
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
|
-
<
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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-
|
|
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
|
+
}
|