@open-press/core 0.5.0 → 0.7.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 +9 -5
- package/engine/cli.mjs +2 -5
- package/engine/commands/_shared.mjs +4 -4
- package/engine/commands/deploy.mjs +1 -1
- package/engine/commands/inspect.mjs +3 -3
- package/engine/commands/replace.mjs +1 -1
- package/engine/commands/search.mjs +1 -1
- package/engine/commands/validate.mjs +2 -2
- package/engine/document-export.mjs +1 -1
- package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
- package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
- package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
- package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
- package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
- package/engine/react/caption-numbering.mjs +73 -0
- package/engine/react/comment-marker.mjs +54 -10
- package/engine/react/document-entry.mjs +124 -64
- package/engine/react/document-export.mjs +252 -311
- package/engine/react/mdx-compile.mjs +123 -3
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/pagination/allocator.mjs +122 -0
- package/engine/react/pagination/regions.mjs +81 -0
- package/engine/react/pagination.mjs +9 -121
- package/engine/react/pipeline/allocate.mjs +248 -0
- package/engine/react/pipeline/final-render.mjs +94 -0
- package/engine/react/pipeline/frame-measurement.mjs +271 -0
- package/engine/react/pipeline/press-tree.mjs +135 -0
- package/engine/react/project-asset-endpoint.mjs +2 -2
- package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
- package/engine/react/sources/heading-numbering.mjs +132 -0
- package/engine/react/sources/mdx-resolver.mjs +441 -0
- package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
- package/engine/{config.mjs → runtime/config.mjs} +15 -0
- package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
- package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
- package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
- package/engine/runtime/source-workspace.mjs +186 -0
- package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
- package/package.json +5 -2
- package/src/openpress/anchorMap.ts +27 -0
- package/src/openpress/core/Frame.tsx +80 -0
- package/src/openpress/core/FrameContext.tsx +19 -0
- package/src/openpress/core/MdxArea.tsx +35 -0
- package/src/openpress/core/Press.tsx +34 -0
- package/src/openpress/core/index.tsx +34 -15
- package/src/openpress/core/primitives.tsx +23 -0
- package/src/openpress/core/types.ts +131 -19
- package/src/openpress/core/useSource.ts +28 -0
- package/src/openpress/manuscript/index.tsx +196 -0
- package/src/openpress/mdx/index.ts +88 -0
- package/src/openpress/numbering/index.ts +294 -0
- package/src/openpress/publicPage.tsx +4 -186
- package/src/openpress/reactDocumentMetadata.ts +2 -16
- package/src/openpress/types.ts +0 -16
- package/src/openpress/workbench.tsx +2 -36
- package/src/styles/openpress/responsive.css +0 -14
- package/tsconfig.json +4 -1
- package/vite.config.ts +10 -3
- package/engine/commands/migrate-to-react.mjs +0 -27
- package/engine/page-renderer.mjs +0 -217
- package/engine/react/migrate-to-react.mjs +0 -355
- package/engine/source-workspace.mjs +0 -76
- package/src/openpress/core/basePages.tsx +0 -87
- package/src/openpress/pagination.ts +0 -845
- /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
- /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
- /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
- /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
- /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
- /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @open-press/core/numbering — label formatters for AI authoring layer.
|
|
2
|
+
//
|
|
3
|
+
// Core stays neutral about how chapter / section / topic numbers should look.
|
|
4
|
+
// Source resolution exposes raw integers via outline items
|
|
5
|
+
// (`chapterNumber`, `sectionIndex`, `topicIndex`). This module is a toolbox
|
|
6
|
+
// of pure functions that turn those integers into human-readable labels —
|
|
7
|
+
// Chinese numerals, Roman numerals, alphabet letters, padded decimals, etc.
|
|
8
|
+
//
|
|
9
|
+
// Workspaces import what they need and compose them inside their own
|
|
10
|
+
// `<Page>` / `<Toc>` components. The core engine never calls these.
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// CJK numeral systems
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const CJK_INFORMAL = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
|
|
17
|
+
const CJK_FORMAL = ["零", "壹", "貳", "參", "肆", "伍", "陸", "柒", "捌", "玖"];
|
|
18
|
+
const CJK_HEAVENLY_STEMS = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"];
|
|
19
|
+
const CJK_EARTHLY_BRANCHES = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Informal CJK numerals (一二三...). Handles 0 to 9999.
|
|
23
|
+
*
|
|
24
|
+
* toCjk(1) → "一"
|
|
25
|
+
* toCjk(10) → "十"
|
|
26
|
+
* toCjk(15) → "十五"
|
|
27
|
+
* toCjk(23) → "二十三"
|
|
28
|
+
* toCjk(100) → "一百"
|
|
29
|
+
* toCjk(108) → "一百零八"
|
|
30
|
+
* toCjk(2024) → "二千零二十四"
|
|
31
|
+
*/
|
|
32
|
+
export function toCjk(n: number): string {
|
|
33
|
+
if (!Number.isFinite(n) || n < 0) return String(n);
|
|
34
|
+
return formatCjkDigits(Math.floor(n), CJK_INFORMAL);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Formal CJK numerals (壹貳參...). Used in legal / financial contexts.
|
|
39
|
+
*
|
|
40
|
+
* toCjkFormal(1) → "壹"
|
|
41
|
+
* toCjkFormal(100) → "壹佰"
|
|
42
|
+
*/
|
|
43
|
+
export function toCjkFormal(n: number): string {
|
|
44
|
+
if (!Number.isFinite(n) || n < 0) return String(n);
|
|
45
|
+
return formatCjkDigits(Math.floor(n), CJK_FORMAL, { formal: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Heavenly Stems cycle (甲乙丙丁戊己庚辛壬癸 — 10 only).
|
|
50
|
+
* Cycles for n > 10; n=11 → 甲 again.
|
|
51
|
+
*
|
|
52
|
+
* toCjkHeavenlyStem(1) → "甲"
|
|
53
|
+
* toCjkHeavenlyStem(10) → "癸"
|
|
54
|
+
* toCjkHeavenlyStem(11) → "甲"
|
|
55
|
+
*/
|
|
56
|
+
export function toCjkHeavenlyStem(n: number): string {
|
|
57
|
+
if (!Number.isFinite(n) || n < 1) return String(n);
|
|
58
|
+
return CJK_HEAVENLY_STEMS[(Math.floor(n) - 1) % 10]!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Earthly Branches cycle (子丑寅卯辰巳午未申酉戌亥 — 12 only).
|
|
63
|
+
* Cycles for n > 12.
|
|
64
|
+
*/
|
|
65
|
+
export function toCjkEarthlyBranch(n: number): string {
|
|
66
|
+
if (!Number.isFinite(n) || n < 1) return String(n);
|
|
67
|
+
return CJK_EARTHLY_BRANCHES[(Math.floor(n) - 1) % 12]!;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Roman numerals
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const ROMAN_TABLE: Array<[number, string]> = [
|
|
75
|
+
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],
|
|
76
|
+
[100, "C"], [90, "XC"], [50, "L"], [40, "XL"],
|
|
77
|
+
[10, "X"], [9, "IX"], [5, "V"], [4, "IV"],
|
|
78
|
+
[1, "I"],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Roman numerals (I, II, III, …, MMXX). Uppercase by default.
|
|
83
|
+
* Pass `{ upper: false }` for lowercase (typical front-matter page numbering).
|
|
84
|
+
*
|
|
85
|
+
* toRoman(1) → "I"
|
|
86
|
+
* toRoman(4) → "IV"
|
|
87
|
+
* toRoman(2024) → "MMXXIV"
|
|
88
|
+
* toRoman(3, { upper: false }) → "iii"
|
|
89
|
+
*/
|
|
90
|
+
export function toRoman(n: number, opts: { upper?: boolean } = {}): string {
|
|
91
|
+
if (!Number.isFinite(n) || n < 1) return String(n);
|
|
92
|
+
let value = Math.floor(n);
|
|
93
|
+
let out = "";
|
|
94
|
+
for (const [num, sym] of ROMAN_TABLE) {
|
|
95
|
+
while (value >= num) {
|
|
96
|
+
out += sym;
|
|
97
|
+
value -= num;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return opts.upper === false ? out.toLowerCase() : out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Latin alphabet (a, b, c, …, z, aa, ab, …)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Spreadsheet-column-style alphabet labels.
|
|
109
|
+
*
|
|
110
|
+
* toAlpha(1) → "a"
|
|
111
|
+
* toAlpha(26) → "z"
|
|
112
|
+
* toAlpha(27) → "aa"
|
|
113
|
+
* toAlpha(53) → "ba"
|
|
114
|
+
* toAlpha(1, { upper: true }) → "A"
|
|
115
|
+
*/
|
|
116
|
+
export function toAlpha(n: number, opts: { upper?: boolean } = {}): string {
|
|
117
|
+
if (!Number.isFinite(n) || n < 1) return String(n);
|
|
118
|
+
let value = Math.floor(n);
|
|
119
|
+
let out = "";
|
|
120
|
+
while (value > 0) {
|
|
121
|
+
const rem = (value - 1) % 26;
|
|
122
|
+
out = String.fromCharCode(97 + rem) + out;
|
|
123
|
+
value = Math.floor((value - 1) / 26);
|
|
124
|
+
}
|
|
125
|
+
return opts.upper ? out.toUpperCase() : out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Zero-padded decimal
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Zero-padded decimal — `width` is the minimum number of digits.
|
|
134
|
+
*
|
|
135
|
+
* toPadded(1) → "01"
|
|
136
|
+
* toPadded(1, { width: 3 }) → "001"
|
|
137
|
+
* toPadded(123) → "123"
|
|
138
|
+
*/
|
|
139
|
+
export function toPadded(n: number, opts: { width?: number } = {}): string {
|
|
140
|
+
if (!Number.isFinite(n)) return String(n);
|
|
141
|
+
const width = Math.max(1, opts.width ?? 2);
|
|
142
|
+
return String(Math.floor(n)).padStart(width, "0");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Composable label templates
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export type NumberFormat =
|
|
150
|
+
| "decimal"
|
|
151
|
+
| "decimal-padded"
|
|
152
|
+
| "cjk"
|
|
153
|
+
| "cjk-formal"
|
|
154
|
+
| "cjk-heavenly-stem"
|
|
155
|
+
| "cjk-earthly-branch"
|
|
156
|
+
| "roman"
|
|
157
|
+
| "roman-lower"
|
|
158
|
+
| "alpha"
|
|
159
|
+
| "alpha-upper";
|
|
160
|
+
|
|
161
|
+
export interface ChapterLabelOptions {
|
|
162
|
+
format?: NumberFormat;
|
|
163
|
+
prefix?: string;
|
|
164
|
+
suffix?: string;
|
|
165
|
+
width?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compose a chapter label like "第一章" / "Chapter 1" / "01" / "Part IV".
|
|
170
|
+
*
|
|
171
|
+
* chapterLabel(1)
|
|
172
|
+
* → "第一章" (default: cjk informal, prefix 第, suffix 章)
|
|
173
|
+
* chapterLabel(1, { format: "decimal-padded", prefix: "Chapter " })
|
|
174
|
+
* → "Chapter 01"
|
|
175
|
+
* chapterLabel(4, { format: "roman", prefix: "Part " })
|
|
176
|
+
* → "Part IV"
|
|
177
|
+
* chapterLabel(2, { prefix: "", suffix: "" })
|
|
178
|
+
* → "二"
|
|
179
|
+
*/
|
|
180
|
+
export function chapterLabel(n: number, opts: ChapterLabelOptions = {}): string {
|
|
181
|
+
const { format = "cjk", prefix = "第", suffix = "章", width } = opts;
|
|
182
|
+
return `${prefix}${formatNumber(n, format, width)}${suffix}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface SectionLabelOptions {
|
|
186
|
+
format?: NumberFormat;
|
|
187
|
+
separator?: string;
|
|
188
|
+
prefix?: string;
|
|
189
|
+
suffix?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Compose a section label like "1.1" / "1.1.2" / "一-1".
|
|
194
|
+
*
|
|
195
|
+
* Accepts variadic counters for nesting:
|
|
196
|
+
* sectionLabel(1, 2)
|
|
197
|
+
* → "1.2"
|
|
198
|
+
* sectionLabel(1, 2, 3)
|
|
199
|
+
* → "1.2.3"
|
|
200
|
+
* sectionLabel(1, 2, { format: "cjk", separator: "之" })
|
|
201
|
+
* ⚠ note: pass options as last argument when using variadic counters;
|
|
202
|
+
* prefer `sectionLabelOf([1, 2], { … })` below for clarity.
|
|
203
|
+
*/
|
|
204
|
+
export function sectionLabel(...counters: number[]): string {
|
|
205
|
+
return sectionLabelOf(counters);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Explicit-array variant of `sectionLabel` so options can be passed cleanly.
|
|
210
|
+
*
|
|
211
|
+
* sectionLabelOf([1, 2], { separator: "-" }) → "1-2"
|
|
212
|
+
* sectionLabelOf([1, 2, 3], { format: "alpha" }) → "a.b.c"
|
|
213
|
+
* sectionLabelOf([1, 1], { format: "cjk", separator: "-" }) → "一-一"
|
|
214
|
+
*/
|
|
215
|
+
export function sectionLabelOf(counters: number[], opts: SectionLabelOptions = {}): string {
|
|
216
|
+
const { format = "decimal", separator = ".", prefix = "", suffix = "" } = opts;
|
|
217
|
+
const parts = counters.map((c) => formatNumber(c, format));
|
|
218
|
+
return `${prefix}${parts.join(separator)}${suffix}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Generic dispatch — `format` -> formatter
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Format a single number using one of the named formats. Useful when the
|
|
227
|
+
* caller already knows which format string to use (e.g. from config).
|
|
228
|
+
*
|
|
229
|
+
* formatNumber(7, "cjk") → "七"
|
|
230
|
+
* formatNumber(7, "roman-lower") → "vii"
|
|
231
|
+
* formatNumber(3, "decimal-padded", 3) → "003"
|
|
232
|
+
*/
|
|
233
|
+
export function formatNumber(n: number, format: NumberFormat, width?: number): string {
|
|
234
|
+
switch (format) {
|
|
235
|
+
case "decimal": return String(Math.floor(n));
|
|
236
|
+
case "decimal-padded": return toPadded(n, { width });
|
|
237
|
+
case "cjk": return toCjk(n);
|
|
238
|
+
case "cjk-formal": return toCjkFormal(n);
|
|
239
|
+
case "cjk-heavenly-stem": return toCjkHeavenlyStem(n);
|
|
240
|
+
case "cjk-earthly-branch": return toCjkEarthlyBranch(n);
|
|
241
|
+
case "roman": return toRoman(n, { upper: true });
|
|
242
|
+
case "roman-lower": return toRoman(n, { upper: false });
|
|
243
|
+
case "alpha": return toAlpha(n);
|
|
244
|
+
case "alpha-upper": return toAlpha(n, { upper: true });
|
|
245
|
+
default: return String(n);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// CJK digit formatter (internal)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const CJK_UNITS = ["", "十", "百", "千"];
|
|
254
|
+
const CJK_UNITS_FORMAL = ["", "拾", "佰", "仟"];
|
|
255
|
+
|
|
256
|
+
// Gap-zero indicator used between non-zero digits when reading aloud
|
|
257
|
+
// (e.g. 一百零八). Distinct from 〇/零 as a literal zero digit.
|
|
258
|
+
const CJK_GAP_ZERO = "零";
|
|
259
|
+
|
|
260
|
+
function formatCjkDigits(n: number, digits: string[], opts: { formal?: boolean } = {}): string {
|
|
261
|
+
if (n === 0) return digits[0]!;
|
|
262
|
+
if (n < 0) return `負${formatCjkDigits(-n, digits, opts)}`;
|
|
263
|
+
if (n >= 10000) {
|
|
264
|
+
const wan = Math.floor(n / 10000);
|
|
265
|
+
const rest = n % 10000;
|
|
266
|
+
if (rest === 0) return `${formatCjkDigits(wan, digits, opts)}萬`;
|
|
267
|
+
const restStr = formatCjkDigits(rest, digits, opts);
|
|
268
|
+
const padded = rest < 1000 ? `${CJK_GAP_ZERO}${restStr}` : restStr;
|
|
269
|
+
return `${formatCjkDigits(wan, digits, opts)}萬${padded}`;
|
|
270
|
+
}
|
|
271
|
+
const units = opts.formal ? CJK_UNITS_FORMAL : CJK_UNITS;
|
|
272
|
+
const str = String(n);
|
|
273
|
+
let out = "";
|
|
274
|
+
let zeroPending = false;
|
|
275
|
+
for (let i = 0; i < str.length; i++) {
|
|
276
|
+
const digit = Number(str[i]);
|
|
277
|
+
const unit = str.length - 1 - i;
|
|
278
|
+
if (digit === 0) {
|
|
279
|
+
zeroPending = true;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (zeroPending && out.length > 0) {
|
|
283
|
+
out += CJK_GAP_ZERO;
|
|
284
|
+
zeroPending = false;
|
|
285
|
+
}
|
|
286
|
+
// Special case: leading "一十" usually written as just "十" in informal CJK
|
|
287
|
+
if (!opts.formal && digit === 1 && unit === 1 && i === 0) {
|
|
288
|
+
out += units[unit];
|
|
289
|
+
} else {
|
|
290
|
+
out += digits[digit] + units[unit];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
@@ -9,13 +9,12 @@ import {
|
|
|
9
9
|
type RefObject,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { BookOpen, ExternalLink, X } from "lucide-react";
|
|
12
|
+
import { createAnchorPageMap, resolveAnchorPageIndex } from "./anchorMap";
|
|
12
13
|
import { collectBookmarkIndex } from "./indexes";
|
|
13
14
|
import type { InspectorState } from "./inspector";
|
|
14
|
-
import { normalizeContentCaptions, paginateSourcePages, type PaginatedPage } from "./pagination";
|
|
15
15
|
import { getProjectIdentity } from "./projectIdentity";
|
|
16
|
-
import { hasBuildTimePagination } from "./reactDocumentMetadata";
|
|
17
16
|
import { useReaderRuntime } from "./readerRuntime";
|
|
18
|
-
import { scheduleBrowserFrame
|
|
17
|
+
import { scheduleBrowserFrame } from "./frameScheduler";
|
|
19
18
|
import type { DeploymentInfo, ReaderDocument, HtmlPageBlock } from "./types";
|
|
20
19
|
import { Bookmarks, CurrentPagePanel } from "./workbenchPanels";
|
|
21
20
|
import type { DisplayPage } from "./workbenchTypes";
|
|
@@ -38,15 +37,9 @@ export function PublicViewer({
|
|
|
38
37
|
deploymentInfo?: DeploymentInfo;
|
|
39
38
|
}) {
|
|
40
39
|
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
41
|
-
const
|
|
40
|
+
const displayPages = pages;
|
|
42
41
|
const viewModeState = useViewMode();
|
|
43
42
|
const { viewMode } = viewModeState;
|
|
44
|
-
const buildTimePaginated = hasBuildTimePagination(document);
|
|
45
|
-
const paginatedPages = usePaginatedPages(numberedPages, sourceContainerRef, viewMode === "paged" && !buildTimePaginated);
|
|
46
|
-
const displayPages: DisplayPage[] = viewMode === "paged" && !buildTimePaginated
|
|
47
|
-
? (paginatedPages ?? numberedPages)
|
|
48
|
-
: numberedPages;
|
|
49
|
-
const paginatedReady = viewMode === "reading" || buildTimePaginated || Boolean(paginatedPages);
|
|
50
43
|
const bookmarks = collectBookmarkIndex(displayPages);
|
|
51
44
|
const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
|
|
52
45
|
const reader = useReaderRuntime({
|
|
@@ -87,7 +80,6 @@ export function PublicViewer({
|
|
|
87
80
|
className={appClassName}
|
|
88
81
|
data-openpress-react-runtime="true"
|
|
89
82
|
data-openpress-view-mode={viewMode}
|
|
90
|
-
data-openpress-pagination={paginatedReady ? "ready" : "pending"}
|
|
91
83
|
>
|
|
92
84
|
{drawerOpen && (
|
|
93
85
|
<div className="openpress-public-scrim" aria-hidden="true" onClick={reader.toggleRightPanel} />
|
|
@@ -102,7 +94,6 @@ export function PublicViewer({
|
|
|
102
94
|
pages={displayPages}
|
|
103
95
|
currentPageIndex={reader.currentPageIndex}
|
|
104
96
|
devMode={false}
|
|
105
|
-
paginatedReady={paginatedReady}
|
|
106
97
|
sourceContainerRef={sourceContainerRef}
|
|
107
98
|
registerPage={reader.registerPage}
|
|
108
99
|
onInternalAnchorNavigate={selectPublicAnchor}
|
|
@@ -196,12 +187,8 @@ export function PrintDocument({
|
|
|
196
187
|
pages: Array<HtmlPageBlock>;
|
|
197
188
|
style: CSSProperties;
|
|
198
189
|
}) {
|
|
199
|
-
const numberedPages = useMemo(() => numberSourceHeadings(pages), [pages]);
|
|
200
190
|
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
201
|
-
const
|
|
202
|
-
const paginatedPages = usePaginatedPages(numberedPages, sourceContainerRef, !buildTimePaginated);
|
|
203
|
-
const displayPages: DisplayPage[] = buildTimePaginated ? numberedPages : (paginatedPages ?? numberedPages);
|
|
204
|
-
const paginatedReady = buildTimePaginated || Boolean(paginatedPages);
|
|
191
|
+
const displayPages = pages;
|
|
205
192
|
const registerPage = () => () => undefined;
|
|
206
193
|
|
|
207
194
|
return (
|
|
@@ -209,14 +196,12 @@ export function PrintDocument({
|
|
|
209
196
|
className="openpress-print-document"
|
|
210
197
|
style={style}
|
|
211
198
|
data-openpress-print-document="true"
|
|
212
|
-
data-openpress-pagination={paginatedReady ? "ready" : "pending"}
|
|
213
199
|
aria-label={`${document.meta.title} PDF 輸出`}
|
|
214
200
|
>
|
|
215
201
|
<PublicPage
|
|
216
202
|
pages={displayPages}
|
|
217
203
|
currentPageIndex={0}
|
|
218
204
|
devMode={false}
|
|
219
|
-
paginatedReady={paginatedReady}
|
|
220
205
|
sourceContainerRef={sourceContainerRef}
|
|
221
206
|
registerPage={registerPage}
|
|
222
207
|
exposeSourceData
|
|
@@ -225,137 +210,15 @@ export function PrintDocument({
|
|
|
225
210
|
);
|
|
226
211
|
}
|
|
227
212
|
|
|
228
|
-
function usePaginatedPages(
|
|
229
|
-
pages: Array<HtmlPageBlock>,
|
|
230
|
-
sourceContainerRef: RefObject<HTMLDivElement | null>,
|
|
231
|
-
enabled = true,
|
|
232
|
-
) {
|
|
233
|
-
const [paginatedPages, setPaginatedPages] = useState<PaginatedPage[] | null>(null);
|
|
234
|
-
|
|
235
|
-
useLayoutEffect(() => {
|
|
236
|
-
setPaginatedPages(null);
|
|
237
|
-
}, [pages]);
|
|
238
|
-
|
|
239
|
-
useLayoutEffect(() => {
|
|
240
|
-
if (!enabled) return undefined;
|
|
241
|
-
if (paginatedPages) return undefined;
|
|
242
|
-
const sourceContainer = sourceContainerRef.current;
|
|
243
|
-
if (!sourceContainer) return undefined;
|
|
244
|
-
|
|
245
|
-
let cancelled = false;
|
|
246
|
-
|
|
247
|
-
void (async () => {
|
|
248
|
-
await waitForPaginationAssets(sourceContainer);
|
|
249
|
-
await waitForBrowserFrame();
|
|
250
|
-
if (cancelled) return;
|
|
251
|
-
|
|
252
|
-
const nextPages = paginateSourcePages(sourceContainer, pages);
|
|
253
|
-
if (!cancelled) setPaginatedPages(nextPages);
|
|
254
|
-
})();
|
|
255
|
-
|
|
256
|
-
return () => {
|
|
257
|
-
cancelled = true;
|
|
258
|
-
};
|
|
259
|
-
}, [enabled, pages, paginatedPages, sourceContainerRef]);
|
|
260
|
-
|
|
261
|
-
return paginatedPages;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
213
|
function viewportAllowsPagedMode() {
|
|
265
214
|
if (typeof window === "undefined") return true;
|
|
266
215
|
return window.innerWidth >= PAGED_VIEW_MIN_WIDTH;
|
|
267
216
|
}
|
|
268
217
|
|
|
269
|
-
export function numberSourceHeadings(pages: Array<HtmlPageBlock>): Array<HtmlPageBlock> {
|
|
270
|
-
if (typeof document === "undefined") return pages;
|
|
271
|
-
|
|
272
|
-
let chapterCounter = 0;
|
|
273
|
-
let sectionCounter = 0;
|
|
274
|
-
let topicCounter = 0;
|
|
275
|
-
|
|
276
|
-
const parsedPages = pages.map((page) => {
|
|
277
|
-
const template = document.createElement("template");
|
|
278
|
-
template.innerHTML = page.html;
|
|
279
|
-
const readerPage = template.content.querySelector<HTMLElement>(".reader-page");
|
|
280
|
-
if (!readerPage || !isContentPage(readerPage)) return { page, template, readerPage };
|
|
281
|
-
|
|
282
|
-
readerPage.querySelectorAll<HTMLElement>("h2, h3, h4").forEach((heading) => {
|
|
283
|
-
if (heading.tagName === "H2") {
|
|
284
|
-
chapterCounter += 1;
|
|
285
|
-
sectionCounter = 0;
|
|
286
|
-
topicCounter = 0;
|
|
287
|
-
ensureHeadingId(heading, `section-${String(chapterCounter).padStart(2, "0")}`);
|
|
288
|
-
heading.dataset.chapter = String(chapterCounter).padStart(2, "0");
|
|
289
|
-
heading.dataset.chapterMarker = `#${chapterCounter}`;
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (heading.tagName === "H3") {
|
|
294
|
-
sectionCounter += 1;
|
|
295
|
-
topicCounter = 0;
|
|
296
|
-
ensureHeadingId(heading, `section-${chapterCounter}-${sectionCounter}`);
|
|
297
|
-
heading.dataset.section = `${chapterCounter}.${sectionCounter}`;
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (heading.tagName === "H4") {
|
|
302
|
-
topicCounter += 1;
|
|
303
|
-
if (chapterCounter > 0 && sectionCounter > 0) {
|
|
304
|
-
ensureHeadingId(heading, `section-${chapterCounter}-${sectionCounter}-${topicCounter}`);
|
|
305
|
-
heading.dataset.topic = `${chapterCounter}.${sectionCounter}.${topicCounter}`;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
return { page, template, readerPage };
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
normalizeContentCaptions(
|
|
314
|
-
parsedPages
|
|
315
|
-
.map((entry) => entry.readerPage)
|
|
316
|
-
.filter((page): page is HTMLElement => Boolean(page)),
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
return parsedPages.map(({ page, template }) => {
|
|
320
|
-
const html = template.innerHTML;
|
|
321
|
-
const anchors = collectElementIds(template.content);
|
|
322
|
-
return html === page.html && anchorsAreEqual(page.anchors, anchors) ? page : { ...page, html, anchors };
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function isContentPage(page: HTMLElement) {
|
|
327
|
-
return page.dataset.pageKind === "content";
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function waitForPaginationAssets(scope: HTMLElement) {
|
|
331
|
-
await document.fonts?.ready;
|
|
332
|
-
const images = Array.from(scope.querySelectorAll<HTMLImageElement>("img"));
|
|
333
|
-
await Promise.all(images.map(waitForImage));
|
|
334
|
-
await waitForBrowserFrame();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function waitForImage(img: HTMLImageElement) {
|
|
338
|
-
if (!img.complete) {
|
|
339
|
-
await new Promise<void>((resolve) => {
|
|
340
|
-
const settle = () => {
|
|
341
|
-
img.removeEventListener("load", settle);
|
|
342
|
-
img.removeEventListener("error", settle);
|
|
343
|
-
resolve();
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
img.addEventListener("load", settle, { once: true });
|
|
347
|
-
img.addEventListener("error", settle, { once: true });
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
await img.decode?.().catch(() => undefined);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
218
|
export function PublicPage({
|
|
355
219
|
pages,
|
|
356
220
|
currentPageIndex,
|
|
357
221
|
devMode,
|
|
358
|
-
paginatedReady,
|
|
359
222
|
sourceContainerRef,
|
|
360
223
|
registerPage,
|
|
361
224
|
exposeSourceData = false,
|
|
@@ -365,7 +228,6 @@ export function PublicPage({
|
|
|
365
228
|
pages: DisplayPage[];
|
|
366
229
|
currentPageIndex: number;
|
|
367
230
|
devMode: boolean;
|
|
368
|
-
paginatedReady: boolean;
|
|
369
231
|
sourceContainerRef: RefObject<HTMLDivElement | null>;
|
|
370
232
|
registerPage: (pageIndex: number) => RefCallback<HTMLElement>;
|
|
371
233
|
exposeSourceData?: boolean;
|
|
@@ -408,11 +270,6 @@ export function PublicPage({
|
|
|
408
270
|
data-source-path={exposeSourceData ? page.source?.path : undefined}
|
|
409
271
|
data-source-file={exposeSourceData ? page.source?.file : undefined}
|
|
410
272
|
>
|
|
411
|
-
{devMode && !paginatedReady && page.source?.path ? (
|
|
412
|
-
<div className="openpress-html-page__toolbar">
|
|
413
|
-
<code>{page.source.path}</code>
|
|
414
|
-
</div>
|
|
415
|
-
) : null}
|
|
416
273
|
<div className="openpress-html-page__html" dangerouslySetInnerHTML={{ __html: page.html }} />
|
|
417
274
|
</div>
|
|
418
275
|
))}
|
|
@@ -420,45 +277,6 @@ export function PublicPage({
|
|
|
420
277
|
);
|
|
421
278
|
}
|
|
422
279
|
|
|
423
|
-
export function createAnchorPageMap(pages: DisplayPage[]) {
|
|
424
|
-
const map = new Map<string, number>();
|
|
425
|
-
pages.forEach((page, index) => {
|
|
426
|
-
page.anchors?.forEach((anchor) => {
|
|
427
|
-
if (anchor && !map.has(anchor)) map.set(anchor, index);
|
|
428
|
-
});
|
|
429
|
-
});
|
|
430
|
-
return map;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
export function resolveAnchorPageIndex(
|
|
434
|
-
anchorPageMap: Map<string, number>,
|
|
435
|
-
pageCount: number,
|
|
436
|
-
anchorId: string,
|
|
437
|
-
pageIndex?: number,
|
|
438
|
-
): number | null {
|
|
439
|
-
if (typeof pageIndex === "number" && Number.isInteger(pageIndex) && pageIndex >= 0 && pageIndex < pageCount) return pageIndex;
|
|
440
|
-
const mapped = anchorPageMap.get(anchorId);
|
|
441
|
-
return mapped === undefined ? null : mapped;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function ensureHeadingId(heading: HTMLElement, fallbackId: string) {
|
|
445
|
-
if (heading.id) return;
|
|
446
|
-
heading.id = fallbackId;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function collectElementIds(scope: ParentNode) {
|
|
450
|
-
const ids: string[] = [];
|
|
451
|
-
scope.querySelectorAll<HTMLElement>("[id]").forEach((el) => {
|
|
452
|
-
if (el.id && !ids.includes(el.id)) ids.push(el.id);
|
|
453
|
-
});
|
|
454
|
-
return ids;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function anchorsAreEqual(left: string[] | undefined, right: string[]) {
|
|
458
|
-
if (!left || left.length !== right.length) return false;
|
|
459
|
-
return left.every((item, index) => item === right[index]);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
280
|
function safeDecodeAnchor(value: string) {
|
|
463
281
|
if (!value) return "";
|
|
464
282
|
try {
|
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ReaderDocument,
|
|
3
|
-
BuildPagination,
|
|
4
3
|
SourceBlock,
|
|
5
4
|
} from "./types";
|
|
6
5
|
|
|
7
|
-
export const
|
|
8
|
-
export const BUILD_TIME_PAGINATION_MODE = "build-time-block-measurement";
|
|
6
|
+
export const PRESS_TREE_MDX_SOURCE_TYPE = "openpress-press-tree-mdx";
|
|
9
7
|
|
|
10
8
|
export function isReactMdxDocument(document: Pick<ReaderDocument, "source"> | null | undefined) {
|
|
11
|
-
return document?.source?.type ===
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function hasBuildTimePagination(document: Pick<ReaderDocument, "source"> | null | undefined) {
|
|
15
|
-
return getBuildPagination(document)?.mode === BUILD_TIME_PAGINATION_MODE;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function getBuildPagination(
|
|
19
|
-
document: Pick<ReaderDocument, "source"> | null | undefined,
|
|
20
|
-
): BuildPagination | null {
|
|
21
|
-
if (!isReactMdxDocument(document)) return null;
|
|
22
|
-
const pagination = document?.source?.pagination;
|
|
23
|
-
return pagination && typeof pagination.mode === "string" ? pagination : null;
|
|
9
|
+
return document?.source?.type === PRESS_TREE_MDX_SOURCE_TYPE;
|
|
24
10
|
}
|
|
25
11
|
|
|
26
12
|
export function getSourceBlockMap(
|
package/src/openpress/types.ts
CHANGED
|
@@ -25,7 +25,6 @@ export interface DocumentSource {
|
|
|
25
25
|
editMode?: string;
|
|
26
26
|
styles?: DocumentStyle[];
|
|
27
27
|
blockMap?: Record<string, SourceBlock>;
|
|
28
|
-
pagination?: BuildPagination;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
export interface DocumentStyle {
|
|
@@ -52,21 +51,6 @@ export interface SourceBlock {
|
|
|
52
51
|
source?: SourceLocation;
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
export interface BuildPagination {
|
|
56
|
-
mode: string;
|
|
57
|
-
pageSafeHeightPx?: number;
|
|
58
|
-
warnings?: PaginationWarning[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface PaginationWarning {
|
|
62
|
-
code: string;
|
|
63
|
-
blockId?: string;
|
|
64
|
-
height?: number;
|
|
65
|
-
pageSafeHeightPx?: number;
|
|
66
|
-
path?: string;
|
|
67
|
-
source?: SourceLocation;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
54
|
export interface DocumentMeta {
|
|
71
55
|
title: string;
|
|
72
56
|
subtitle?: string;
|