@open-press/core 0.7.1 → 0.8.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/engine/commands/dev.mjs +2 -2
- package/engine/output/chrome-pdf.mjs +18 -3
- package/engine/output/static-server.mjs +39 -0
- package/engine/react/comment-endpoint.mjs +13 -39
- package/engine/react/comment-marker.mjs +30 -6
- package/engine/react/document-entry.mjs +11 -0
- package/engine/react/document-export.mjs +30 -5
- package/engine/react/http-json.mjs +24 -0
- package/engine/react/mdx-compile.mjs +96 -3
- package/engine/react/measurement-css.mjs +93 -1
- package/engine/react/object-entities.mjs +119 -0
- package/engine/react/pipeline/allocate.mjs +10 -7
- package/engine/react/pipeline/frame-measurement.mjs +2 -0
- package/engine/react/project-asset-endpoint.mjs +6 -24
- package/engine/react/source-edit-endpoint.d.mts +10 -0
- package/engine/react/source-edit-endpoint.mjs +75 -0
- package/engine/react/sources/mdx-resolver.mjs +12 -14
- package/engine/react/style-discovery.mjs +1 -4
- package/engine/runtime/file-walk.mjs +22 -0
- package/engine/runtime/inspection.mjs +1 -20
- package/engine/runtime/path-utils.mjs +20 -0
- package/engine/runtime/source-text-tools.d.mts +102 -0
- package/engine/runtime/source-text-tools.mjs +551 -16
- package/engine/runtime/source-workspace.mjs +4 -31
- package/package.json +1 -1
- package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
- package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
- package/src/openpress/app/index.ts +2 -0
- package/src/openpress/core/Frame.tsx +9 -11
- package/src/openpress/core/FrameContext.tsx +8 -3
- package/src/openpress/core/MdxArea.tsx +11 -12
- package/src/openpress/core/cn.ts +4 -0
- package/src/openpress/core/index.tsx +2 -1
- package/src/openpress/core/primitives.tsx +29 -8
- package/src/openpress/core/types.ts +8 -0
- package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
- package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
- package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
- package/src/openpress/document-model/index.ts +6 -0
- package/src/openpress/document-model/objectEntityModel.ts +51 -0
- package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
- package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
- package/src/openpress/manuscript/index.tsx +49 -7
- package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
- package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
- package/src/openpress/reader/index.ts +10 -0
- package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
- package/src/openpress/reader/readerTypes.ts +4 -0
- package/src/openpress/reader/usePageViewportScale.ts +119 -0
- package/src/openpress/reader/usePanelState.ts +56 -0
- package/src/openpress/reader/useReaderHashSync.ts +61 -0
- package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
- package/src/openpress/reader/useReaderRuntime.ts +146 -0
- package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
- package/src/openpress/shared/Panel.tsx +77 -0
- package/src/openpress/shared/index.ts +4 -0
- package/src/openpress/shared/numberUtils.ts +3 -0
- package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
- package/src/openpress/workbench/Workbench.tsx +407 -0
- package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
- package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
- package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
- package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
- package/src/openpress/workbench/actions/index.ts +5 -0
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
- package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
- package/src/openpress/workbench/dialog/index.ts +1 -0
- package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
- package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
- package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
- package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
- package/src/openpress/workbench/document/index.ts +10 -0
- package/src/openpress/workbench/index.ts +2 -0
- package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
- package/src/openpress/workbench/inspector/index.ts +5 -0
- package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
- package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
- package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
- package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
- package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
- package/src/openpress/workbench/mentions/index.ts +2 -0
- package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
- package/src/openpress/workbench/panels/Panel.tsx +1 -0
- package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
- package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
- package/src/openpress/workbench/panels/index.ts +3 -0
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
- package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
- package/src/openpress/workbench/project/index.ts +2 -0
- package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
- package/src/openpress/workbench/shell/index.ts +1 -0
- package/src/openpress/workbench/workbenchFormatters.ts +120 -0
- package/src/openpress/workbench/workbenchTypes.ts +35 -0
- package/src/styles/openpress/print-route.css +0 -2
- package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
- package/src/styles/openpress/public-viewer.css +25 -320
- package/src/styles/openpress/reader-runtime.css +243 -55
- package/src/styles/openpress/responsive.css +145 -270
- package/src/styles/openpress/workbench-panels.css +214 -178
- package/src/styles/openpress/workbench.css +986 -451
- package/src/styles/openpress.css +1 -1
- package/vite.config.ts +50 -0
- package/src/openpress/inspector.ts +0 -282
- package/src/openpress/projectWorkspace.tsx +0 -919
- package/src/openpress/readerRuntime.ts +0 -230
- package/src/openpress/workbench.tsx +0 -1265
- package/src/openpress/workbenchTypes.ts +0 -4
- /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
- /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
- /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
- /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
- /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
- /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import {
|
|
2
|
-
useLayoutEffect,
|
|
3
2
|
useMemo,
|
|
4
3
|
useRef,
|
|
5
|
-
useState,
|
|
6
4
|
type CSSProperties,
|
|
7
5
|
type MouseEvent as ReactMouseEvent,
|
|
8
6
|
type RefCallback,
|
|
9
7
|
type RefObject,
|
|
10
8
|
} from "react";
|
|
11
9
|
import { BookOpen, ExternalLink, X } from "lucide-react";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
import {
|
|
11
|
+
collectBookmarkIndex,
|
|
12
|
+
createAnchorPageMap,
|
|
13
|
+
createPageObjectEntityId,
|
|
14
|
+
getProjectIdentity,
|
|
15
|
+
resolveAnchorPageIndex,
|
|
16
|
+
type DeploymentInfo,
|
|
17
|
+
type HtmlPageBlock,
|
|
18
|
+
type ReaderDocument,
|
|
19
|
+
} from "../document-model";
|
|
20
|
+
import type { InspectorState } from "../workbench/inspector";
|
|
21
|
+
import { useReaderRuntime } from "./useReaderRuntime";
|
|
22
|
+
import { Bookmarks, CurrentPagePanel } from "./ReaderNavigationPanel";
|
|
23
|
+
import type { DisplayPage } from "./readerTypes";
|
|
24
|
+
import { usePageViewportScale } from "./usePageViewportScale";
|
|
25
|
+
import type { PageLayoutMode } from "./pageViewportScaleModel";
|
|
21
26
|
|
|
22
27
|
export const PUBLIC_DRAWER_BREAKPOINT = 1185;
|
|
23
|
-
export type ViewMode = "
|
|
28
|
+
export type ViewMode = "paged";
|
|
24
29
|
export type PageInspector = Pick<InspectorState, "enabled" | "handleClick">;
|
|
25
30
|
|
|
26
|
-
const PAGED_VIEW_MIN_WIDTH = 360;
|
|
27
|
-
|
|
28
31
|
export function PublicViewer({
|
|
29
32
|
document,
|
|
30
33
|
pages,
|
|
@@ -46,6 +49,12 @@ export function PublicViewer({
|
|
|
46
49
|
pageCount: displayPages.length,
|
|
47
50
|
rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
|
|
48
51
|
});
|
|
52
|
+
usePageViewportScale({
|
|
53
|
+
stageRef: reader.stageRef,
|
|
54
|
+
pageContainerRef: sourceContainerRef,
|
|
55
|
+
pageCount: displayPages.length,
|
|
56
|
+
layoutMode: "single",
|
|
57
|
+
});
|
|
49
58
|
const currentPage = displayPages[reader.currentPageIndex];
|
|
50
59
|
const staticPdfHref = deploymentInfo.pdf;
|
|
51
60
|
const projectIdentity = getProjectIdentity(document.meta);
|
|
@@ -135,7 +144,7 @@ export function PublicViewer({
|
|
|
135
144
|
totalPageLabel={reader.totalPageLabel}
|
|
136
145
|
progressPercent={reader.progressPercent}
|
|
137
146
|
title={currentPage?.title || document.meta.title}
|
|
138
|
-
pageLabelPrefix=
|
|
147
|
+
pageLabelPrefix="頁"
|
|
139
148
|
showHeading={false}
|
|
140
149
|
showTitle={false}
|
|
141
150
|
/>
|
|
@@ -145,37 +154,8 @@ export function PublicViewer({
|
|
|
145
154
|
);
|
|
146
155
|
}
|
|
147
156
|
|
|
148
|
-
export function useViewMode() {
|
|
149
|
-
|
|
150
|
-
if (typeof window === "undefined") return true;
|
|
151
|
-
return viewportAllowsPagedMode();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
useLayoutEffect(() => {
|
|
155
|
-
if (typeof window === "undefined") return undefined;
|
|
156
|
-
|
|
157
|
-
let cancelFrame: (() => void) | null = null;
|
|
158
|
-
const sync = () => {
|
|
159
|
-
cancelFrame?.();
|
|
160
|
-
cancelFrame = scheduleBrowserFrame(() => {
|
|
161
|
-
cancelFrame = null;
|
|
162
|
-
setPagedAllowed(viewportAllowsPagedMode());
|
|
163
|
-
});
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
sync();
|
|
167
|
-
window.addEventListener("resize", sync);
|
|
168
|
-
window.visualViewport?.addEventListener("resize", sync);
|
|
169
|
-
return () => {
|
|
170
|
-
window.removeEventListener("resize", sync);
|
|
171
|
-
window.visualViewport?.removeEventListener("resize", sync);
|
|
172
|
-
cancelFrame?.();
|
|
173
|
-
};
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
const viewMode: ViewMode = pagedAllowed ? "paged" : "reading";
|
|
177
|
-
|
|
178
|
-
return { viewMode };
|
|
157
|
+
export function useViewMode(): { viewMode: ViewMode } {
|
|
158
|
+
return { viewMode: "paged" };
|
|
179
159
|
}
|
|
180
160
|
|
|
181
161
|
export function PrintDocument({
|
|
@@ -210,11 +190,6 @@ export function PrintDocument({
|
|
|
210
190
|
);
|
|
211
191
|
}
|
|
212
192
|
|
|
213
|
-
function viewportAllowsPagedMode() {
|
|
214
|
-
if (typeof window === "undefined") return true;
|
|
215
|
-
return window.innerWidth >= PAGED_VIEW_MIN_WIDTH;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
193
|
export function PublicPage({
|
|
219
194
|
pages,
|
|
220
195
|
currentPageIndex,
|
|
@@ -224,6 +199,7 @@ export function PublicPage({
|
|
|
224
199
|
exposeSourceData = false,
|
|
225
200
|
inspector,
|
|
226
201
|
onInternalAnchorNavigate,
|
|
202
|
+
pageLayoutMode = "single",
|
|
227
203
|
}: {
|
|
228
204
|
pages: DisplayPage[];
|
|
229
205
|
currentPageIndex: number;
|
|
@@ -233,6 +209,7 @@ export function PublicPage({
|
|
|
233
209
|
exposeSourceData?: boolean;
|
|
234
210
|
inspector?: PageInspector;
|
|
235
211
|
onInternalAnchorNavigate?: (anchorId: string, pageIndex?: number) => boolean;
|
|
212
|
+
pageLayoutMode?: PageLayoutMode;
|
|
236
213
|
}) {
|
|
237
214
|
const handlePageClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
238
215
|
if (inspector?.enabled && inspector.handleClick(event)) return;
|
|
@@ -257,6 +234,7 @@ export function PublicPage({
|
|
|
257
234
|
className="reader-pages openpress-public-page"
|
|
258
235
|
ref={sourceContainerRef}
|
|
259
236
|
data-openpress-public-page="true"
|
|
237
|
+
data-openpress-page-layout={pageLayoutMode}
|
|
260
238
|
onClick={handlePageClick}
|
|
261
239
|
>
|
|
262
240
|
{pages.map((page) => (
|
|
@@ -265,7 +243,9 @@ export function PublicPage({
|
|
|
265
243
|
ref={registerPage(page.pageNumber - 1)}
|
|
266
244
|
id={`page-${String(page.pageNumber).padStart(2, "0")}`}
|
|
267
245
|
className="openpress-html-page"
|
|
246
|
+
data-openpress-object-id={page.frameKey ? createPageObjectEntityId(page.frameKey) : undefined}
|
|
268
247
|
data-openpress-page-index={page.pageNumber - 1}
|
|
248
|
+
data-openpress-page-spread-side={pageLayoutMode === "spread" ? ((page.pageNumber - 1) % 2 === 0 ? "left" : "right") : undefined}
|
|
269
249
|
data-openpress-active={currentPageIndex === page.pageNumber - 1 ? "true" : "false"}
|
|
270
250
|
data-source-path={exposeSourceData ? page.source?.path : undefined}
|
|
271
251
|
data-source-file={exposeSourceData ? page.source?.file : undefined}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
|
|
2
|
-
import type { BookmarkItem } from "
|
|
2
|
+
import type { BookmarkItem } from "../document-model";
|
|
3
|
+
import { Panel } from "../shared";
|
|
3
4
|
|
|
4
5
|
type BookmarkSelectOptions = {
|
|
5
6
|
behavior?: ScrollBehavior;
|
|
@@ -20,7 +21,7 @@ export function Bookmarks({
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
if (items.length === 0) {
|
|
23
|
-
return <
|
|
24
|
+
return <Panel.Empty className="openpress-asset-empty" role="status">尚無書籤</Panel.Empty>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
return (
|
|
@@ -103,8 +104,8 @@ export function CurrentPagePanel({
|
|
|
103
104
|
showTitle?: boolean;
|
|
104
105
|
}) {
|
|
105
106
|
return (
|
|
106
|
-
<
|
|
107
|
-
{showHeading ? <
|
|
107
|
+
<Panel.Section className="openpress-panel-section--current" aria-label="目前頁面">
|
|
108
|
+
{showHeading ? <Panel.SectionTitle className="openpress-panel-heading">目前頁面</Panel.SectionTitle> : null}
|
|
108
109
|
<div className="openpress-current-page-card">
|
|
109
110
|
<div className="openpress-current-page-card__number" aria-label="目前頁數">
|
|
110
111
|
{pageLabelPrefix ? <span className="openpress-current-page-card__prefix">{pageLabelPrefix}</span> : null}
|
|
@@ -117,6 +118,6 @@ export function CurrentPagePanel({
|
|
|
117
118
|
<span style={{ "--progress": `${progressPercent}%` } as CSSProperties} />
|
|
118
119
|
</div>
|
|
119
120
|
</div>
|
|
120
|
-
</
|
|
121
|
+
</Panel.Section>
|
|
121
122
|
);
|
|
122
123
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./PublicReaderPage";
|
|
2
|
+
export * from "./ReaderNavigationPanel";
|
|
3
|
+
export * from "./pageViewportScaleModel";
|
|
4
|
+
export * from "./readerPageRegistry";
|
|
5
|
+
export * from "./readerPageRoute";
|
|
6
|
+
export * from "./readerScroll";
|
|
7
|
+
export * from "./readerStateModel";
|
|
8
|
+
export * from "./readerTypes";
|
|
9
|
+
export * from "./usePageViewportScale";
|
|
10
|
+
export * from "./useReaderRuntime";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type PageLayoutMode = "single" | "spread";
|
|
2
|
+
|
|
3
|
+
export type PageViewportScaleMode =
|
|
4
|
+
| "fit-width"
|
|
5
|
+
| "fit-page"
|
|
6
|
+
| "scale-25"
|
|
7
|
+
| "scale-50"
|
|
8
|
+
| "scale-75"
|
|
9
|
+
| "scale-100"
|
|
10
|
+
| "scale-125"
|
|
11
|
+
| "scale-150"
|
|
12
|
+
| "scale-200";
|
|
13
|
+
|
|
14
|
+
export const PAGE_VIEWPORT_SCALE_OPTIONS: Array<{
|
|
15
|
+
value: PageViewportScaleMode;
|
|
16
|
+
label: string;
|
|
17
|
+
}> = [
|
|
18
|
+
{ value: "scale-25", label: "25%" },
|
|
19
|
+
{ value: "scale-50", label: "50%" },
|
|
20
|
+
{ value: "scale-75", label: "75%" },
|
|
21
|
+
{ value: "scale-100", label: "100%" },
|
|
22
|
+
{ value: "scale-125", label: "125%" },
|
|
23
|
+
{ value: "scale-150", label: "150%" },
|
|
24
|
+
{ value: "scale-200", label: "200%" },
|
|
25
|
+
{ value: "fit-width", label: "符合頁面寬度" },
|
|
26
|
+
{ value: "fit-page", label: "符合全開頁面" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const MIN_PAGE_VIEWPORT_SCALE = 0.12;
|
|
30
|
+
const MAX_FIT_PAGE_VIEWPORT_SCALE = 1;
|
|
31
|
+
const MAX_FIXED_PAGE_VIEWPORT_SCALE = 2;
|
|
32
|
+
|
|
33
|
+
export function resolvePageViewportScale({
|
|
34
|
+
mode,
|
|
35
|
+
fitWidthScale,
|
|
36
|
+
fitPageScale,
|
|
37
|
+
}: {
|
|
38
|
+
mode: PageViewportScaleMode;
|
|
39
|
+
fitWidthScale: number;
|
|
40
|
+
fitPageScale: number;
|
|
41
|
+
}) {
|
|
42
|
+
if (mode === "fit-width") return clampPageViewportScale(fitWidthScale, MAX_FIT_PAGE_VIEWPORT_SCALE);
|
|
43
|
+
if (mode === "fit-page") return clampPageViewportScale(fitPageScale, MAX_FIT_PAGE_VIEWPORT_SCALE);
|
|
44
|
+
return scaleModeToFixedValue(mode);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatPageViewportScaleLabel(mode: PageViewportScaleMode, scale: number) {
|
|
48
|
+
void mode;
|
|
49
|
+
return formatPageViewportScalePercent(scale);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatPageViewportScalePercent(scale: number) {
|
|
53
|
+
return `${Math.round(clampPageViewportScale(scale, MAX_FIXED_PAGE_VIEWPORT_SCALE) * 100)}%`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatPageViewportScaleValue(scale: number) {
|
|
57
|
+
return clampPageViewportScale(scale, MAX_FIXED_PAGE_VIEWPORT_SCALE)
|
|
58
|
+
.toFixed(4)
|
|
59
|
+
.replace(/0+$/, "")
|
|
60
|
+
.replace(/\.$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function scaleModeToFixedValue(mode: PageViewportScaleMode) {
|
|
64
|
+
const match = /^scale-(\d+)$/.exec(mode);
|
|
65
|
+
if (!match) return 1;
|
|
66
|
+
return clampPageViewportScale(Number.parseInt(match[1] ?? "100", 10) / 100, MAX_FIXED_PAGE_VIEWPORT_SCALE);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clampPageViewportScale(value: number, maxScale: number) {
|
|
70
|
+
if (!Number.isFinite(value)) return 1;
|
|
71
|
+
const safeMaxScale = Number.isFinite(maxScale) && maxScale > 0 ? maxScale : MAX_FIXED_PAGE_VIEWPORT_SCALE;
|
|
72
|
+
return Math.min(Math.max(value, MIN_PAGE_VIEWPORT_SCALE), safeMaxScale);
|
|
73
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useState, type RefObject } from "react";
|
|
2
|
+
import { scheduleBrowserFrame } from "../shared";
|
|
3
|
+
import {
|
|
4
|
+
formatPageViewportScaleLabel,
|
|
5
|
+
formatPageViewportScaleValue,
|
|
6
|
+
resolvePageViewportScale,
|
|
7
|
+
type PageLayoutMode,
|
|
8
|
+
type PageViewportScaleMode,
|
|
9
|
+
} from "./pageViewportScaleModel";
|
|
10
|
+
|
|
11
|
+
export function usePageViewportScale({
|
|
12
|
+
stageRef,
|
|
13
|
+
pageContainerRef,
|
|
14
|
+
pageCount,
|
|
15
|
+
layoutMode = "single",
|
|
16
|
+
}: {
|
|
17
|
+
stageRef: RefObject<HTMLElement | null>;
|
|
18
|
+
pageContainerRef: RefObject<HTMLElement | null>;
|
|
19
|
+
pageCount: number;
|
|
20
|
+
layoutMode?: PageLayoutMode;
|
|
21
|
+
}) {
|
|
22
|
+
const [scaleMode, setScaleMode] = useState<PageViewportScaleMode>("fit-width");
|
|
23
|
+
const [scale, setScale] = useState(1);
|
|
24
|
+
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
if (typeof window === "undefined") return undefined;
|
|
27
|
+
|
|
28
|
+
let cancelFrame: (() => void) | null = null;
|
|
29
|
+
|
|
30
|
+
const syncScale = () => {
|
|
31
|
+
cancelFrame?.();
|
|
32
|
+
cancelFrame = scheduleBrowserFrame(() => {
|
|
33
|
+
cancelFrame = null;
|
|
34
|
+
const container = pageContainerRef.current;
|
|
35
|
+
if (!container) return;
|
|
36
|
+
|
|
37
|
+
const pageSurface = container.querySelector<HTMLElement>(".openpress-html-page__html");
|
|
38
|
+
if (!pageSurface) {
|
|
39
|
+
container.style.setProperty("--openpress-page-viewport-scale", "1");
|
|
40
|
+
container.dataset.openpressPageScaleMode = scaleMode;
|
|
41
|
+
container.dataset.openpressPageScale = "1";
|
|
42
|
+
setScale(1);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stage = stageRef.current ?? container.parentElement;
|
|
47
|
+
const containerStyle = window.getComputedStyle(container);
|
|
48
|
+
const paddingLeft = parseCssPixelValue(containerStyle.paddingLeft);
|
|
49
|
+
const paddingRight = parseCssPixelValue(containerStyle.paddingRight);
|
|
50
|
+
const paddingTop = parseCssPixelValue(containerStyle.paddingTop);
|
|
51
|
+
const paddingBottom = parseCssPixelValue(containerStyle.paddingBottom);
|
|
52
|
+
const columnGap = parseCssPixelValue(containerStyle.columnGap || containerStyle.gap);
|
|
53
|
+
const availableWidth = Math.max(
|
|
54
|
+
1,
|
|
55
|
+
(stage?.clientWidth || container.clientWidth || window.innerWidth) - paddingLeft - paddingRight,
|
|
56
|
+
);
|
|
57
|
+
const availableHeight = Math.max(
|
|
58
|
+
1,
|
|
59
|
+
(stage?.clientHeight || container.clientHeight || window.innerHeight) - paddingTop - paddingBottom,
|
|
60
|
+
);
|
|
61
|
+
const pageWidth = pageSurface.offsetWidth;
|
|
62
|
+
const pageHeight = pageSurface.offsetHeight;
|
|
63
|
+
const canonicalWidth = layoutMode === "spread" ? (pageWidth * 2) + columnGap : pageWidth;
|
|
64
|
+
const canonicalHeight = pageHeight;
|
|
65
|
+
const fitWidthScale = canonicalWidth > 0 ? availableWidth / canonicalWidth : 1;
|
|
66
|
+
const fitPageScale = canonicalWidth > 0 && canonicalHeight > 0
|
|
67
|
+
? Math.min(availableWidth / canonicalWidth, availableHeight / canonicalHeight)
|
|
68
|
+
: 1;
|
|
69
|
+
const nextScale = resolvePageViewportScale({ mode: scaleMode, fitWidthScale, fitPageScale });
|
|
70
|
+
const nextScaleValue = formatPageViewportScaleValue(nextScale);
|
|
71
|
+
|
|
72
|
+
container.style.setProperty("--openpress-page-viewport-scale", nextScaleValue);
|
|
73
|
+
container.dataset.openpressPageScaleMode = scaleMode;
|
|
74
|
+
container.dataset.openpressPageScale = nextScaleValue;
|
|
75
|
+
setScale((current) => (current === nextScale ? current : nextScale));
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
syncScale();
|
|
80
|
+
|
|
81
|
+
const ResizeObserverCtor = window.ResizeObserver;
|
|
82
|
+
const observer = ResizeObserverCtor ? new ResizeObserverCtor(syncScale) : null;
|
|
83
|
+
const stage = stageRef.current;
|
|
84
|
+
const container = pageContainerRef.current;
|
|
85
|
+
if (stage) observer?.observe(stage);
|
|
86
|
+
if (container) observer?.observe(container);
|
|
87
|
+
|
|
88
|
+
window.addEventListener("resize", syncScale);
|
|
89
|
+
window.visualViewport?.addEventListener("resize", syncScale);
|
|
90
|
+
return () => {
|
|
91
|
+
cancelFrame?.();
|
|
92
|
+
observer?.disconnect();
|
|
93
|
+
window.removeEventListener("resize", syncScale);
|
|
94
|
+
window.visualViewport?.removeEventListener("resize", syncScale);
|
|
95
|
+
};
|
|
96
|
+
}, [layoutMode, pageContainerRef, pageCount, scaleMode, stageRef]);
|
|
97
|
+
|
|
98
|
+
const scaleLabel = useMemo(
|
|
99
|
+
() => {
|
|
100
|
+
const labelScale = scaleMode.startsWith("scale-")
|
|
101
|
+
? resolvePageViewportScale({ mode: scaleMode, fitWidthScale: scale, fitPageScale: scale })
|
|
102
|
+
: scale;
|
|
103
|
+
return formatPageViewportScaleLabel(scaleMode, labelScale);
|
|
104
|
+
},
|
|
105
|
+
[scale, scaleMode],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
scale,
|
|
110
|
+
scaleMode,
|
|
111
|
+
scaleLabel,
|
|
112
|
+
setScaleMode,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseCssPixelValue(value: string) {
|
|
117
|
+
const parsed = Number.parseFloat(value);
|
|
118
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UsePanelStateOptions {
|
|
4
|
+
leftPanelBreakpoint?: number;
|
|
5
|
+
rightPanelBreakpoint?: number;
|
|
6
|
+
onAfterResize?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PanelState {
|
|
10
|
+
leftPanelOpen: boolean;
|
|
11
|
+
rightPanelOpen: boolean;
|
|
12
|
+
toggleLeftPanel: () => void;
|
|
13
|
+
toggleRightPanel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function usePanelState({
|
|
17
|
+
leftPanelBreakpoint,
|
|
18
|
+
rightPanelBreakpoint = 1000,
|
|
19
|
+
onAfterResize,
|
|
20
|
+
}: UsePanelStateOptions = {}): PanelState {
|
|
21
|
+
const shouldOpenLeftPanel = useCallback(
|
|
22
|
+
() =>
|
|
23
|
+
leftPanelBreakpoint === undefined || typeof window === "undefined" || window.innerWidth >= leftPanelBreakpoint,
|
|
24
|
+
[leftPanelBreakpoint],
|
|
25
|
+
);
|
|
26
|
+
const shouldOpenRightPanel = useCallback(
|
|
27
|
+
() => typeof window === "undefined" || window.innerWidth >= rightPanelBreakpoint,
|
|
28
|
+
[rightPanelBreakpoint],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const [rightPanelOpen, setRightPanelOpen] = useState(false);
|
|
32
|
+
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (typeof window === "undefined") return undefined;
|
|
36
|
+
|
|
37
|
+
const handleResize = () => {
|
|
38
|
+
setLeftPanelOpen((open) => (open && !shouldOpenLeftPanel() ? false : open));
|
|
39
|
+
setRightPanelOpen((open) => (open && !shouldOpenRightPanel() ? false : open));
|
|
40
|
+
onAfterResize?.();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
handleResize();
|
|
44
|
+
window.addEventListener("resize", handleResize);
|
|
45
|
+
window.visualViewport?.addEventListener("resize", handleResize);
|
|
46
|
+
return () => {
|
|
47
|
+
window.removeEventListener("resize", handleResize);
|
|
48
|
+
window.visualViewport?.removeEventListener("resize", handleResize);
|
|
49
|
+
};
|
|
50
|
+
}, [shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
|
|
51
|
+
|
|
52
|
+
const toggleLeftPanel = useCallback(() => setLeftPanelOpen((open) => !open), []);
|
|
53
|
+
const toggleRightPanel = useCallback(() => setRightPanelOpen((open) => !open), []);
|
|
54
|
+
|
|
55
|
+
return { leftPanelOpen, rightPanelOpen, toggleLeftPanel, toggleRightPanel };
|
|
56
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, type MutableRefObject } from "react";
|
|
2
|
+
import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute";
|
|
3
|
+
import { scrollToPage } from "./readerScroll";
|
|
4
|
+
|
|
5
|
+
export interface UseReaderHashSyncOptions {
|
|
6
|
+
stageRef: MutableRefObject<HTMLElement | null>;
|
|
7
|
+
pageRefs: MutableRefObject<Array<HTMLElement | null>>;
|
|
8
|
+
currentPageIndex: number;
|
|
9
|
+
currentPageIndexRef: MutableRefObject<number>;
|
|
10
|
+
normalizedPageCount: number;
|
|
11
|
+
setCurrentPageIndex: (index: number) => void;
|
|
12
|
+
armPendingScrollTarget: (target: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useReaderHashSync({
|
|
16
|
+
stageRef,
|
|
17
|
+
pageRefs,
|
|
18
|
+
currentPageIndex,
|
|
19
|
+
currentPageIndexRef,
|
|
20
|
+
normalizedPageCount,
|
|
21
|
+
setCurrentPageIndex,
|
|
22
|
+
armPendingScrollTarget,
|
|
23
|
+
}: UseReaderHashSyncOptions) {
|
|
24
|
+
// Mirror currentPageIndex into the URL hash so deep links + history work.
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (typeof window === "undefined") return;
|
|
27
|
+
replacePageRoute(currentPageIndex);
|
|
28
|
+
}, [currentPageIndex]);
|
|
29
|
+
|
|
30
|
+
// Listen for hash/back/forward navigation and drive scroll to match.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (typeof window === "undefined") return undefined;
|
|
33
|
+
|
|
34
|
+
const syncFromHash = (behavior: ScrollBehavior) => {
|
|
35
|
+
const refs = pageRefs.current;
|
|
36
|
+
const hashPage = pageIndexFromHash(window.location.hash, normalizedPageCount);
|
|
37
|
+
if (hashPage === null) return;
|
|
38
|
+
// replacePageRoute writes the hash to mirror state; skip if it already
|
|
39
|
+
// matches so we don't fight ourselves.
|
|
40
|
+
if (hashPage === currentPageIndexRef.current) return;
|
|
41
|
+
armPendingScrollTarget(hashPage);
|
|
42
|
+
setCurrentPageIndex(hashPage);
|
|
43
|
+
scrollToPage(refs, hashPage, behavior, stageRef.current);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onHashChange = () => syncFromHash("smooth");
|
|
47
|
+
window.addEventListener("hashchange", onHashChange);
|
|
48
|
+
window.addEventListener("popstate", onHashChange);
|
|
49
|
+
return () => {
|
|
50
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
51
|
+
window.removeEventListener("popstate", onHashChange);
|
|
52
|
+
};
|
|
53
|
+
}, [
|
|
54
|
+
armPendingScrollTarget,
|
|
55
|
+
currentPageIndexRef,
|
|
56
|
+
normalizedPageCount,
|
|
57
|
+
pageRefs,
|
|
58
|
+
setCurrentPageIndex,
|
|
59
|
+
stageRef,
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseReaderKeyboardNavOptions {
|
|
4
|
+
nextPage: () => void;
|
|
5
|
+
prevPage: () => void;
|
|
6
|
+
setPage: (pageIndex: number) => void;
|
|
7
|
+
normalizedPageCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useReaderKeyboardNav({
|
|
11
|
+
nextPage,
|
|
12
|
+
prevPage,
|
|
13
|
+
setPage,
|
|
14
|
+
normalizedPageCount,
|
|
15
|
+
}: UseReaderKeyboardNavOptions) {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
18
|
+
if (isEditableTarget(event.target)) return;
|
|
19
|
+
if (hasActiveTextSelection()) return;
|
|
20
|
+
if (event.key === "ArrowRight" || event.key === "PageDown") {
|
|
21
|
+
event.preventDefault();
|
|
22
|
+
nextPage();
|
|
23
|
+
} else if (event.key === "ArrowLeft" || event.key === "PageUp") {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
prevPage();
|
|
26
|
+
} else if (event.key === "Home") {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
setPage(0);
|
|
29
|
+
} else if (event.key === "End") {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
setPage(Math.max(0, normalizedPageCount - 1));
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
35
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
36
|
+
}, [nextPage, prevPage, setPage, normalizedPageCount]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
40
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
41
|
+
return Boolean(target.closest("input, textarea, select, button, [contenteditable]"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasActiveTextSelection() {
|
|
45
|
+
const selection = window.getSelection?.();
|
|
46
|
+
if (!selection || selection.isCollapsed) return false;
|
|
47
|
+
return Boolean(selection.toString().trim());
|
|
48
|
+
}
|