@open-press/core 0.8.0 → 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.
- package/README.md +6 -3
- package/engine/cli.mjs +8 -8
- package/engine/commands/_shared.mjs +37 -15
- package/engine/commands/image.mjs +29 -0
- package/engine/commands/skills-sync.mjs +71 -0
- package/engine/commands/typecheck.mjs +63 -1
- package/engine/commands/upgrade.mjs +3 -3
- package/engine/document-export.mjs +1 -1
- package/engine/output/chrome-pdf.mjs +92 -0
- package/engine/output/static-server.mjs +48 -9
- package/engine/react/comment-marker.mjs +13 -13
- package/engine/react/document-entry.mjs +35 -28
- package/engine/react/document-export.mjs +309 -170
- package/engine/react/mdx-compile.mjs +30 -0
- package/engine/react/measurement-css.mjs +21 -0
- package/engine/react/object-entities.mjs +85 -0
- package/engine/react/pagination/allocator.mjs +48 -3
- package/engine/react/pagination.mjs +1 -1
- package/engine/react/pipeline/allocate.mjs +31 -65
- package/engine/react/pipeline/frame-measurement.mjs +4 -0
- package/engine/react/press-tree-inspection.mjs +172 -0
- package/engine/react/sources/mdx-resolver.mjs +1 -1
- package/engine/react/style-discovery.mjs +22 -4
- package/engine/runtime/config.d.mts +8 -0
- package/engine/runtime/config.mjs +57 -60
- package/engine/runtime/file-utils.mjs +9 -1
- package/engine/runtime/page-geometry.mjs +131 -0
- package/engine/runtime/source-text-tools.mjs +1 -1
- package/engine/runtime/source-workspace.mjs +12 -3
- package/engine/runtime/validation.mjs +19 -10
- package/package.json +3 -5
- package/src/openpress/app/OpenPressApp.tsx +173 -17
- package/src/openpress/app/OpenPressRuntime.tsx +10 -2
- package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
- package/src/openpress/core/Frame.tsx +20 -7
- package/src/openpress/core/FrameContext.tsx +2 -0
- package/src/openpress/core/Press.tsx +25 -4
- package/src/openpress/core/Workspace.tsx +36 -0
- package/src/openpress/core/index.tsx +10 -3
- package/src/openpress/core/primitives.tsx +48 -1
- package/src/openpress/core/types.ts +86 -41
- package/src/openpress/core/useSource.ts +1 -1
- package/src/openpress/document-model/documentTypes.ts +9 -0
- package/src/openpress/document-model/index.ts +1 -0
- package/src/openpress/document-model/objectEntityModel.ts +4 -0
- package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
- package/src/openpress/mdx/index.ts +15 -7
- package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
- package/src/openpress/reader/index.ts +1 -0
- package/src/openpress/workbench/Workbench.tsx +120 -21
- package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
- package/src/openpress/workbench/actions/SearchControl.tsx +3 -3
- package/src/openpress/workbench/actions/index.ts +1 -0
- package/src/openpress/workbench/inspector/useInspectorComments.ts +7 -1
- package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +5 -1
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +4 -2
- package/src/openpress/workbench/workbenchFormatters.ts +2 -2
- package/src/styles/openpress/reader-runtime.css +9 -0
- package/src/styles/openpress/workbench-panels.css +113 -0
- package/src/styles/openpress/workspace-gallery.css +300 -0
- package/src/styles/openpress.css +1 -0
- package/tsconfig.json +1 -1
- package/engine/commands/init.mjs +0 -24
- 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
|
-
|
|
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
|
|
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
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|