@open-press/core 0.3.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/LICENSE +21 -0
- package/README.md +36 -0
- package/engine/chrome-pdf.d.mts +34 -0
- package/engine/chrome-pdf.mjs +344 -0
- package/engine/cli.mjs +93 -0
- package/engine/commands/_shared.mjs +170 -0
- package/engine/commands/deploy.mjs +31 -0
- package/engine/commands/dev.mjs +26 -0
- package/engine/commands/export.mjs +8 -0
- package/engine/commands/init.mjs +24 -0
- package/engine/commands/inspect.mjs +35 -0
- package/engine/commands/migrate-to-react.mjs +27 -0
- package/engine/commands/pdf.mjs +26 -0
- package/engine/commands/preview.mjs +26 -0
- package/engine/commands/render.mjs +17 -0
- package/engine/commands/replace.mjs +41 -0
- package/engine/commands/search.mjs +33 -0
- package/engine/commands/typecheck.mjs +5 -0
- package/engine/commands/validate.mjs +17 -0
- package/engine/config.d.mts +40 -0
- package/engine/config.mjs +160 -0
- package/engine/deploy-sync.mjs +15 -0
- package/engine/document-export.mjs +15 -0
- package/engine/file-utils.mjs +106 -0
- package/engine/fonts.mjs +62 -0
- package/engine/init.mjs +90 -0
- package/engine/inspection.mjs +348 -0
- package/engine/issue-report.mjs +44 -0
- package/engine/katex-assets.mjs +45 -0
- package/engine/page-block.mjs +30 -0
- package/engine/page-renderer.mjs +217 -0
- package/engine/pdf-media.mjs +45 -0
- package/engine/public-assets.mjs +19 -0
- package/engine/react/chapter-css.mjs +53 -0
- package/engine/react/comment-endpoint.d.mts +11 -0
- package/engine/react/comment-endpoint.mjs +128 -0
- package/engine/react/comment-marker.mjs +306 -0
- package/engine/react/document-entry.mjs +253 -0
- package/engine/react/document-export.mjs +392 -0
- package/engine/react/mdx-compile.mjs +295 -0
- package/engine/react/measurement-css.mjs +44 -0
- package/engine/react/migrate-to-react.mjs +355 -0
- package/engine/react/pagination-constants.mjs +3 -0
- package/engine/react/pagination.mjs +121 -0
- package/engine/react/project-asset-endpoint.d.mts +10 -0
- package/engine/react/project-asset-endpoint.mjs +379 -0
- package/engine/react/workspace-discovery.mjs +156 -0
- package/engine/source-text-tools.mjs +280 -0
- package/engine/source-workspace.mjs +76 -0
- package/engine/static-server.mjs +493 -0
- package/engine/validation.mjs +172 -0
- package/index.html +13 -0
- package/package.json +86 -0
- package/src/openpress/App.tsx +127 -0
- package/src/openpress/composerMentions.ts +188 -0
- package/src/openpress/core/basePages.tsx +87 -0
- package/src/openpress/core/index.tsx +20 -0
- package/src/openpress/core/types.ts +71 -0
- package/src/openpress/frameScheduler.ts +32 -0
- package/src/openpress/indexes.ts +329 -0
- package/src/openpress/inspector.ts +282 -0
- package/src/openpress/pageRoute.ts +21 -0
- package/src/openpress/pagination.ts +845 -0
- package/src/openpress/projectIdentity.ts +15 -0
- package/src/openpress/projectSources.ts +24 -0
- package/src/openpress/projectWorkspace.tsx +919 -0
- package/src/openpress/publicPage.tsx +469 -0
- package/src/openpress/reactDocumentMetadata.ts +41 -0
- package/src/openpress/readerPageRegistry.ts +41 -0
- package/src/openpress/readerRuntime.ts +230 -0
- package/src/openpress/readerScroll.ts +92 -0
- package/src/openpress/readerState.ts +15 -0
- package/src/openpress/renderer.tsx +91 -0
- package/src/openpress/runtimeMode.ts +22 -0
- package/src/openpress/types.ts +112 -0
- package/src/openpress/workbench.tsx +1299 -0
- package/src/openpress/workbenchPanels.tsx +122 -0
- package/src/openpress/workbenchTypes.ts +4 -0
- package/src/styles/openpress/app-shell.css +251 -0
- package/src/styles/openpress/media-workspace.css +230 -0
- package/src/styles/openpress/print-route.css +186 -0
- package/src/styles/openpress/project-workspace.css +1318 -0
- package/src/styles/openpress/public-viewer.css +983 -0
- package/src/styles/openpress/reader-runtime.css +792 -0
- package/src/styles/openpress/responsive.css +384 -0
- package/src/styles/openpress/workbench-panels.css +558 -0
- package/src/styles/openpress/workbench.css +720 -0
- package/src/styles/openpress.css +14 -0
- package/tsconfig.json +37 -0
- package/vite.config.ts +512 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useLayoutEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type MouseEvent as ReactMouseEvent,
|
|
8
|
+
type RefCallback,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { BookOpen, ExternalLink, X } from "lucide-react";
|
|
12
|
+
import { collectBookmarkIndex } from "./indexes";
|
|
13
|
+
import type { InspectorState } from "./inspector";
|
|
14
|
+
import { normalizeContentCaptions, paginateSourcePages, type PaginatedPage } from "./pagination";
|
|
15
|
+
import { getProjectIdentity } from "./projectIdentity";
|
|
16
|
+
import { hasBuildTimePagination } from "./reactDocumentMetadata";
|
|
17
|
+
import { useReaderRuntime } from "./readerRuntime";
|
|
18
|
+
import { scheduleBrowserFrame, waitForBrowserFrame } from "./frameScheduler";
|
|
19
|
+
import type { DeploymentInfo, ReaderDocument, HtmlPageBlock } from "./types";
|
|
20
|
+
import { Bookmarks, CurrentPagePanel } from "./workbenchPanels";
|
|
21
|
+
import type { DisplayPage } from "./workbenchTypes";
|
|
22
|
+
|
|
23
|
+
export const PUBLIC_DRAWER_BREAKPOINT = 1185;
|
|
24
|
+
export type ViewMode = "reading" | "paged";
|
|
25
|
+
export type PageInspector = Pick<InspectorState, "enabled" | "handleClick">;
|
|
26
|
+
|
|
27
|
+
const PAGED_VIEW_MIN_WIDTH = 360;
|
|
28
|
+
|
|
29
|
+
export function PublicViewer({
|
|
30
|
+
document,
|
|
31
|
+
pages,
|
|
32
|
+
style,
|
|
33
|
+
deploymentInfo = { online: false },
|
|
34
|
+
}: {
|
|
35
|
+
document: ReaderDocument;
|
|
36
|
+
pages: Array<HtmlPageBlock>;
|
|
37
|
+
style: CSSProperties;
|
|
38
|
+
deploymentInfo?: DeploymentInfo;
|
|
39
|
+
}) {
|
|
40
|
+
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
41
|
+
const numberedPages = useMemo(() => numberSourceHeadings(pages), [pages]);
|
|
42
|
+
const viewModeState = useViewMode();
|
|
43
|
+
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
|
+
const bookmarks = collectBookmarkIndex(displayPages);
|
|
51
|
+
const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
|
|
52
|
+
const reader = useReaderRuntime({
|
|
53
|
+
pageCount: displayPages.length,
|
|
54
|
+
rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
|
|
55
|
+
});
|
|
56
|
+
const currentPage = displayPages[reader.currentPageIndex];
|
|
57
|
+
const staticPdfHref = deploymentInfo.pdf;
|
|
58
|
+
const projectIdentity = getProjectIdentity(document.meta);
|
|
59
|
+
|
|
60
|
+
const drawerOpen = reader.rightPanelOpen;
|
|
61
|
+
|
|
62
|
+
const selectPublicPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
|
|
63
|
+
reader.setPage(pageIndex, options);
|
|
64
|
+
if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && drawerOpen) reader.toggleRightPanel();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const selectPublicAnchor = (anchorId: string, pageIndex?: number) => {
|
|
68
|
+
const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex);
|
|
69
|
+
if (targetPageIndex === null) return false;
|
|
70
|
+
selectPublicPage(targetPageIndex, { behavior: "smooth" });
|
|
71
|
+
return true;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const appClassName = [
|
|
75
|
+
"reader-app openpress-reader-app openpress-public-viewer is-ready",
|
|
76
|
+
drawerOpen ? "" : "is-closed-right",
|
|
77
|
+
].filter(Boolean).join(" ");
|
|
78
|
+
|
|
79
|
+
const handleOpenStaticPdf = () => {
|
|
80
|
+
if (!staticPdfHref) return;
|
|
81
|
+
window.open(staticPdfHref, "_blank", "noopener,noreferrer");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<main className="openpress-workbench openpress-public-shell" style={style} data-openpress-public-viewer="true" aria-label={`${document.meta.title} 公開頁`}>
|
|
86
|
+
<div
|
|
87
|
+
className={appClassName}
|
|
88
|
+
data-openpress-react-runtime="true"
|
|
89
|
+
data-openpress-view-mode={viewMode}
|
|
90
|
+
data-openpress-pagination={paginatedReady ? "ready" : "pending"}
|
|
91
|
+
>
|
|
92
|
+
{drawerOpen && (
|
|
93
|
+
<div className="openpress-public-scrim" aria-hidden="true" onClick={reader.toggleRightPanel} />
|
|
94
|
+
)}
|
|
95
|
+
<button type="button" className="openpress-public-fab" aria-label="開啟目錄" onClick={reader.toggleRightPanel}>
|
|
96
|
+
<BookOpen size={20} aria-hidden="true" />
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
<section className="openpress-workbench__stage openpress-public-viewer__stage" aria-label="公開文件頁面">
|
|
100
|
+
<main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
|
|
101
|
+
<PublicPage
|
|
102
|
+
pages={displayPages}
|
|
103
|
+
currentPageIndex={reader.currentPageIndex}
|
|
104
|
+
devMode={false}
|
|
105
|
+
paginatedReady={paginatedReady}
|
|
106
|
+
sourceContainerRef={sourceContainerRef}
|
|
107
|
+
registerPage={reader.registerPage}
|
|
108
|
+
onInternalAnchorNavigate={selectPublicAnchor}
|
|
109
|
+
/>
|
|
110
|
+
</main>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<aside className="reader-side-nav openpress-workspace-panel openpress-public-navigation" aria-label="文件導覽">
|
|
114
|
+
<button type="button" className="openpress-public-drawer-close" aria-label="關閉目錄" onClick={reader.toggleRightPanel}>
|
|
115
|
+
<X size={16} aria-hidden="true" />
|
|
116
|
+
</button>
|
|
117
|
+
<section className="openpress-public-identity" aria-label="文件資訊">
|
|
118
|
+
<strong>
|
|
119
|
+
<span className="openpress-public-title-main">{projectIdentity.name}</span>
|
|
120
|
+
{projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
|
|
121
|
+
</strong>
|
|
122
|
+
{projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
|
|
123
|
+
</section>
|
|
124
|
+
<div className="openpress-public-actions" aria-label="文件操作">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
className="openpress-public-export-button"
|
|
128
|
+
data-openpress-public-export
|
|
129
|
+
disabled={!staticPdfHref}
|
|
130
|
+
onClick={handleOpenStaticPdf}
|
|
131
|
+
>
|
|
132
|
+
<ExternalLink aria-hidden="true" />
|
|
133
|
+
{!staticPdfHref ? "PDF 未部署" : "開啟 PDF"}
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
<section id="openpress-bookmarks" className="openpress-panel-section openpress-panel-section--bookmarks" aria-label="章節書籤">
|
|
137
|
+
<nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
|
|
138
|
+
<div className="reader-bookmarks-rail" aria-hidden="true" />
|
|
139
|
+
<Bookmarks items={bookmarks} currentPageIndex={reader.currentPageIndex} onSelectPage={selectPublicPage} />
|
|
140
|
+
</nav>
|
|
141
|
+
</section>
|
|
142
|
+
<CurrentPagePanel
|
|
143
|
+
currentPageLabel={reader.currentPageLabel}
|
|
144
|
+
totalPageLabel={reader.totalPageLabel}
|
|
145
|
+
progressPercent={reader.progressPercent}
|
|
146
|
+
title={currentPage?.title || document.meta.title}
|
|
147
|
+
pageLabelPrefix={viewMode === "reading" ? "節" : "頁"}
|
|
148
|
+
showHeading={false}
|
|
149
|
+
showTitle={false}
|
|
150
|
+
/>
|
|
151
|
+
</aside>
|
|
152
|
+
</div>
|
|
153
|
+
</main>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function useViewMode() {
|
|
158
|
+
const [pagedAllowed, setPagedAllowed] = useState(() => {
|
|
159
|
+
if (typeof window === "undefined") return true;
|
|
160
|
+
return viewportAllowsPagedMode();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
useLayoutEffect(() => {
|
|
164
|
+
if (typeof window === "undefined") return undefined;
|
|
165
|
+
|
|
166
|
+
let cancelFrame: (() => void) | null = null;
|
|
167
|
+
const sync = () => {
|
|
168
|
+
cancelFrame?.();
|
|
169
|
+
cancelFrame = scheduleBrowserFrame(() => {
|
|
170
|
+
cancelFrame = null;
|
|
171
|
+
setPagedAllowed(viewportAllowsPagedMode());
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
sync();
|
|
176
|
+
window.addEventListener("resize", sync);
|
|
177
|
+
window.visualViewport?.addEventListener("resize", sync);
|
|
178
|
+
return () => {
|
|
179
|
+
window.removeEventListener("resize", sync);
|
|
180
|
+
window.visualViewport?.removeEventListener("resize", sync);
|
|
181
|
+
cancelFrame?.();
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const viewMode: ViewMode = pagedAllowed ? "paged" : "reading";
|
|
186
|
+
|
|
187
|
+
return { viewMode };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function PrintDocument({
|
|
191
|
+
document,
|
|
192
|
+
pages,
|
|
193
|
+
style,
|
|
194
|
+
}: {
|
|
195
|
+
document: ReaderDocument;
|
|
196
|
+
pages: Array<HtmlPageBlock>;
|
|
197
|
+
style: CSSProperties;
|
|
198
|
+
}) {
|
|
199
|
+
const numberedPages = useMemo(() => numberSourceHeadings(pages), [pages]);
|
|
200
|
+
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
201
|
+
const buildTimePaginated = hasBuildTimePagination(document);
|
|
202
|
+
const paginatedPages = usePaginatedPages(numberedPages, sourceContainerRef, !buildTimePaginated);
|
|
203
|
+
const displayPages: DisplayPage[] = buildTimePaginated ? numberedPages : (paginatedPages ?? numberedPages);
|
|
204
|
+
const paginatedReady = buildTimePaginated || Boolean(paginatedPages);
|
|
205
|
+
const registerPage = () => () => undefined;
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<main
|
|
209
|
+
className="openpress-print-document"
|
|
210
|
+
style={style}
|
|
211
|
+
data-openpress-print-document="true"
|
|
212
|
+
data-openpress-pagination={paginatedReady ? "ready" : "pending"}
|
|
213
|
+
aria-label={`${document.meta.title} PDF 輸出`}
|
|
214
|
+
>
|
|
215
|
+
<PublicPage
|
|
216
|
+
pages={displayPages}
|
|
217
|
+
currentPageIndex={0}
|
|
218
|
+
devMode={false}
|
|
219
|
+
paginatedReady={paginatedReady}
|
|
220
|
+
sourceContainerRef={sourceContainerRef}
|
|
221
|
+
registerPage={registerPage}
|
|
222
|
+
exposeSourceData
|
|
223
|
+
/>
|
|
224
|
+
</main>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
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
|
+
function viewportAllowsPagedMode() {
|
|
265
|
+
if (typeof window === "undefined") return true;
|
|
266
|
+
return window.innerWidth >= PAGED_VIEW_MIN_WIDTH;
|
|
267
|
+
}
|
|
268
|
+
|
|
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
|
+
export function PublicPage({
|
|
355
|
+
pages,
|
|
356
|
+
currentPageIndex,
|
|
357
|
+
devMode,
|
|
358
|
+
paginatedReady,
|
|
359
|
+
sourceContainerRef,
|
|
360
|
+
registerPage,
|
|
361
|
+
exposeSourceData = false,
|
|
362
|
+
inspector,
|
|
363
|
+
onInternalAnchorNavigate,
|
|
364
|
+
}: {
|
|
365
|
+
pages: DisplayPage[];
|
|
366
|
+
currentPageIndex: number;
|
|
367
|
+
devMode: boolean;
|
|
368
|
+
paginatedReady: boolean;
|
|
369
|
+
sourceContainerRef: RefObject<HTMLDivElement | null>;
|
|
370
|
+
registerPage: (pageIndex: number) => RefCallback<HTMLElement>;
|
|
371
|
+
exposeSourceData?: boolean;
|
|
372
|
+
inspector?: PageInspector;
|
|
373
|
+
onInternalAnchorNavigate?: (anchorId: string, pageIndex?: number) => boolean;
|
|
374
|
+
}) {
|
|
375
|
+
const handlePageClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
376
|
+
if (inspector?.enabled && inspector.handleClick(event)) return;
|
|
377
|
+
if (!onInternalAnchorNavigate || event.defaultPrevented || event.button !== 0) return;
|
|
378
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
|
379
|
+
if (!(event.target instanceof Element)) return;
|
|
380
|
+
|
|
381
|
+
const link = event.target.closest<HTMLAnchorElement>('a[href^="#"]');
|
|
382
|
+
if (!link) return;
|
|
383
|
+
|
|
384
|
+
const href = link.getAttribute("href") ?? "";
|
|
385
|
+
const anchorId = link.dataset.openpressAnchor || safeDecodeAnchor(href.slice(1));
|
|
386
|
+
if (!anchorId) return;
|
|
387
|
+
|
|
388
|
+
const pageIndex = Number.parseInt(link.dataset.openpressTargetPageIndex ?? "", 10);
|
|
389
|
+
const handled = onInternalAnchorNavigate(anchorId, Number.isFinite(pageIndex) ? pageIndex : undefined);
|
|
390
|
+
if (handled) event.preventDefault();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<div
|
|
395
|
+
className="reader-pages openpress-public-page"
|
|
396
|
+
ref={sourceContainerRef}
|
|
397
|
+
data-openpress-public-page="true"
|
|
398
|
+
onClick={handlePageClick}
|
|
399
|
+
>
|
|
400
|
+
{pages.map((page) => (
|
|
401
|
+
<div
|
|
402
|
+
key={page.id}
|
|
403
|
+
ref={registerPage(page.pageNumber - 1)}
|
|
404
|
+
id={`page-${String(page.pageNumber).padStart(2, "0")}`}
|
|
405
|
+
className="openpress-html-page"
|
|
406
|
+
data-openpress-page-index={page.pageNumber - 1}
|
|
407
|
+
data-openpress-active={currentPageIndex === page.pageNumber - 1 ? "true" : "false"}
|
|
408
|
+
data-source-path={exposeSourceData ? page.source?.path : undefined}
|
|
409
|
+
data-source-file={exposeSourceData ? page.source?.file : undefined}
|
|
410
|
+
>
|
|
411
|
+
{devMode && !paginatedReady && page.source?.path ? (
|
|
412
|
+
<div className="openpress-html-page__toolbar">
|
|
413
|
+
<code>{page.source.path}</code>
|
|
414
|
+
</div>
|
|
415
|
+
) : null}
|
|
416
|
+
<div className="openpress-html-page__html" dangerouslySetInnerHTML={{ __html: page.html }} />
|
|
417
|
+
</div>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
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
|
+
function safeDecodeAnchor(value: string) {
|
|
463
|
+
if (!value) return "";
|
|
464
|
+
try {
|
|
465
|
+
return decodeURIComponent(value);
|
|
466
|
+
} catch {
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ReaderDocument,
|
|
3
|
+
BuildPagination,
|
|
4
|
+
SourceBlock,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
export const REACT_MDX_SOURCE_TYPE = "openpress-react-mdx";
|
|
8
|
+
export const BUILD_TIME_PAGINATION_MODE = "build-time-block-measurement";
|
|
9
|
+
|
|
10
|
+
export function isReactMdxDocument(document: Pick<ReaderDocument, "source"> | null | undefined) {
|
|
11
|
+
return document?.source?.type === REACT_MDX_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;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getSourceBlockMap(
|
|
27
|
+
document: Pick<ReaderDocument, "source"> | null | undefined,
|
|
28
|
+
): Record<string, SourceBlock> {
|
|
29
|
+
if (!isReactMdxDocument(document)) return {};
|
|
30
|
+
const blockMap = document?.source?.blockMap;
|
|
31
|
+
if (!blockMap || typeof blockMap !== "object") return {};
|
|
32
|
+
return blockMap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSourceBlock(
|
|
36
|
+
document: Pick<ReaderDocument, "source"> | null | undefined,
|
|
37
|
+
blockId: string | null | undefined,
|
|
38
|
+
): SourceBlock | null {
|
|
39
|
+
if (!blockId) return null;
|
|
40
|
+
return getSourceBlockMap(document)[blockId] ?? null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface ReaderPageRegistry<TNode> {
|
|
2
|
+
refs: Array<TNode | null>;
|
|
3
|
+
registerPage: (pageIndex: number) => (node: TNode | null) => void;
|
|
4
|
+
trim: (pageCount: number) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createReaderPageRegistry<TNode = HTMLElement>(
|
|
8
|
+
onChange: (version: number) => void,
|
|
9
|
+
): ReaderPageRegistry<TNode> {
|
|
10
|
+
const refs: Array<TNode | null> = [];
|
|
11
|
+
const callbacks = new Map<number, (node: TNode | null) => void>();
|
|
12
|
+
let version = 0;
|
|
13
|
+
|
|
14
|
+
const bump = () => {
|
|
15
|
+
version += 1;
|
|
16
|
+
onChange(version);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
refs,
|
|
21
|
+
registerPage(pageIndex: number) {
|
|
22
|
+
const existing = callbacks.get(pageIndex);
|
|
23
|
+
if (existing) return existing;
|
|
24
|
+
|
|
25
|
+
const callback = (node: TNode | null) => {
|
|
26
|
+
if (refs[pageIndex] === node) return;
|
|
27
|
+
refs[pageIndex] = node;
|
|
28
|
+
bump();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
callbacks.set(pageIndex, callback);
|
|
32
|
+
return callback;
|
|
33
|
+
},
|
|
34
|
+
trim(pageCount: number) {
|
|
35
|
+
refs.length = Math.max(pageCount, 0);
|
|
36
|
+
for (const pageIndex of callbacks.keys()) {
|
|
37
|
+
if (pageIndex >= pageCount) callbacks.delete(pageIndex);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|