@open-press/core 1.1.3 → 1.2.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/cli.mjs +2 -2
- package/engine/commands/_shared.mjs +91 -9
- package/engine/commands/image.mjs +20 -4
- package/engine/commands/pdf.mjs +4 -1
- package/engine/output/chrome-pdf.mjs +257 -52
- package/engine/output/static-server.mjs +48 -8
- package/engine/react/document-export.mjs +22 -0
- package/engine/runtime/inspection.mjs +65 -7
- package/engine/runtime/page-selector.mjs +87 -0
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +1 -0
- package/src/openpress/app/OpenPressRuntime.tsx +59 -5
- package/src/openpress/reader/PublicReaderPage.tsx +163 -74
- package/src/openpress/reader/SlidePresentationPage.tsx +8 -3
- package/src/openpress/reader/usePanelState.ts +14 -5
- package/src/openpress/shared/index.ts +1 -0
- package/src/openpress/shared/staticSearch.ts +174 -0
- package/src/openpress/workbench/Workbench.tsx +19 -16
- package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +14 -3
- package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
- package/vite.config.ts +50 -8
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
2
4
|
useMemo,
|
|
3
5
|
useRef,
|
|
6
|
+
useState,
|
|
4
7
|
type CSSProperties,
|
|
5
8
|
type MouseEvent as ReactMouseEvent,
|
|
6
9
|
type RefCallback,
|
|
7
10
|
type RefObject,
|
|
8
11
|
} from "react";
|
|
9
|
-
import {
|
|
12
|
+
import { ExternalLink, Ruler } from "lucide-react";
|
|
10
13
|
import {
|
|
11
14
|
collectBookmarkIndex,
|
|
12
15
|
createAnchorPageMap,
|
|
13
16
|
createPageObjectEntityId,
|
|
14
17
|
getProjectIdentity,
|
|
18
|
+
getSourceBlockMap,
|
|
15
19
|
resolveAnchorPageIndex,
|
|
16
20
|
type DeploymentInfo,
|
|
17
21
|
type HtmlPageBlock,
|
|
18
22
|
type ReaderDocument,
|
|
19
23
|
} from "../document-model";
|
|
20
24
|
import type { InspectorState } from "../workbench/inspector";
|
|
25
|
+
import { groupSourceBlocksByPath } from "../workbench/inspector";
|
|
21
26
|
import { useReaderRuntime } from "./useReaderRuntime";
|
|
22
27
|
import { Bookmarks, CurrentPagePanel } from "./ReaderNavigationPanel";
|
|
23
28
|
import type { DisplayPage } from "./readerTypes";
|
|
24
29
|
import { usePageViewportScale } from "./usePageViewportScale";
|
|
25
30
|
import type { PageLayoutMode } from "./pageViewportScaleModel";
|
|
31
|
+
import { PageZoomControl, SearchControl, type SearchControlSearcher } from "../workbench/actions";
|
|
32
|
+
import { WorkbenchShell } from "../workbench/shell";
|
|
33
|
+
import { formatPageGeometrySpec } from "../workbench/workbenchFormatters";
|
|
34
|
+
import { searchCorpus, type SearchCorpus } from "../shared";
|
|
26
35
|
|
|
27
36
|
export const PUBLIC_DRAWER_BREAKPOINT = 1185;
|
|
28
37
|
export type ViewMode = "paged";
|
|
@@ -41,29 +50,60 @@ export function PublicViewer({
|
|
|
41
50
|
}) {
|
|
42
51
|
const sourceContainerRef = useRef<HTMLDivElement | null>(null);
|
|
43
52
|
const displayPages = pages;
|
|
44
|
-
const
|
|
45
|
-
const { viewMode } = viewModeState;
|
|
53
|
+
const { viewMode } = useViewMode();
|
|
46
54
|
const bookmarks = collectBookmarkIndex(displayPages);
|
|
47
55
|
const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
|
|
48
56
|
const reader = useReaderRuntime({
|
|
49
57
|
pageCount: displayPages.length,
|
|
58
|
+
leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
|
|
50
59
|
rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
|
|
51
60
|
});
|
|
52
|
-
|
|
61
|
+
const [pageLayoutMode, setPageLayoutMode] = useState<PageLayoutMode>("single");
|
|
62
|
+
const pageViewport = usePageViewportScale({
|
|
53
63
|
stageRef: reader.stageRef,
|
|
54
64
|
pageContainerRef: sourceContainerRef,
|
|
55
65
|
pageCount: displayPages.length,
|
|
56
|
-
layoutMode:
|
|
66
|
+
layoutMode: pageLayoutMode,
|
|
57
67
|
});
|
|
58
68
|
const currentPage = displayPages[reader.currentPageIndex];
|
|
59
69
|
const staticPdfHref = deploymentInfo.pdf;
|
|
60
70
|
const projectIdentity = getProjectIdentity(document.meta);
|
|
71
|
+
const pressType = document.meta.type === "slides" ? "slides" : "pages";
|
|
72
|
+
const pageGeometry = formatPageGeometrySpec(document.theme);
|
|
73
|
+
const sourceBlocksByPath = useMemo(
|
|
74
|
+
() => groupSourceBlocksByPath(getSourceBlockMap(document)),
|
|
75
|
+
[document],
|
|
76
|
+
);
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
// Static searcher: lazy-fetch /openpress/search-corpus.json on first
|
|
79
|
+
// query, cache for subsequent searches, then run the same literal-match
|
|
80
|
+
// logic the dev endpoint uses — no backend required for deployed pages.
|
|
81
|
+
const corpusRef = useRef<SearchCorpus | null>(null);
|
|
82
|
+
const corpusFetchRef = useRef<Promise<SearchCorpus> | null>(null);
|
|
83
|
+
const staticSearcher = useCallback<SearchControlSearcher>(async ({ query, scope, signal }) => {
|
|
84
|
+
if (!corpusRef.current) {
|
|
85
|
+
if (!corpusFetchRef.current) {
|
|
86
|
+
corpusFetchRef.current = fetch("/openpress/search-corpus.json", { cache: "force-cache" })
|
|
87
|
+
.then(async (response) => {
|
|
88
|
+
if (!response.ok) throw new Error(`Failed to load search corpus (${response.status})`);
|
|
89
|
+
return (await response.json()) as SearchCorpus;
|
|
90
|
+
})
|
|
91
|
+
.catch((error) => {
|
|
92
|
+
corpusFetchRef.current = null;
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const corpus = await corpusFetchRef.current;
|
|
97
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
98
|
+
corpusRef.current = corpus;
|
|
99
|
+
}
|
|
100
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
101
|
+
return searchCorpus(corpusRef.current, { query, scope, caseSensitive: false });
|
|
102
|
+
}, []);
|
|
63
103
|
|
|
64
104
|
const selectPublicPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
|
|
65
105
|
reader.setPage(pageIndex, options);
|
|
66
|
-
if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT &&
|
|
106
|
+
if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && reader.leftPanelOpen) reader.toggleLeftPanel();
|
|
67
107
|
};
|
|
68
108
|
|
|
69
109
|
const selectPublicAnchor = (anchorId: string, pageIndex?: number) => {
|
|
@@ -73,84 +113,112 @@ export function PublicViewer({
|
|
|
73
113
|
return true;
|
|
74
114
|
};
|
|
75
115
|
|
|
76
|
-
const appClassName = [
|
|
77
|
-
"reader-app openpress-reader-app openpress-public-viewer is-ready",
|
|
78
|
-
drawerOpen ? "" : "is-closed-right",
|
|
79
|
-
].filter(Boolean).join(" ");
|
|
80
|
-
|
|
81
116
|
const handleOpenStaticPdf = () => {
|
|
82
117
|
if (!staticPdfHref) return;
|
|
83
118
|
window.open(staticPdfHref, "_blank", "noopener,noreferrer");
|
|
84
119
|
};
|
|
85
120
|
|
|
86
121
|
return (
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
<WorkbenchShell
|
|
123
|
+
style={style}
|
|
124
|
+
viewMode={viewMode}
|
|
125
|
+
pressType={pressType}
|
|
126
|
+
inspectorMode={false}
|
|
127
|
+
leftPanelOpen={reader.leftPanelOpen}
|
|
128
|
+
rightPanelOpen={false}
|
|
129
|
+
onToggleLeftPanel={reader.toggleLeftPanel}
|
|
130
|
+
onToggleRightPanel={reader.toggleLeftPanel}
|
|
131
|
+
withRightPanel={false}
|
|
132
|
+
publicViewer
|
|
133
|
+
>
|
|
134
|
+
<WorkbenchShell.Toolbar>
|
|
135
|
+
<div className="openpress-workbench-toolbar__group" aria-label="輸出">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className="openpress-workbench-toolbar-action"
|
|
139
|
+
data-openpress-public-export
|
|
140
|
+
disabled={!staticPdfHref}
|
|
141
|
+
onClick={handleOpenStaticPdf}
|
|
142
|
+
title={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
|
|
143
|
+
aria-label={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
|
|
144
|
+
>
|
|
145
|
+
<ExternalLink aria-hidden="true" />
|
|
146
|
+
<span className="openpress-workbench-toolbar-action__label">
|
|
147
|
+
{staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
|
|
148
|
+
</span>
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
className="openpress-workbench-page-geometry"
|
|
155
|
+
data-openpress-page-geometry
|
|
156
|
+
title={pageGeometry.title}
|
|
157
|
+
aria-label={`頁面規格 ${pageGeometry.title}`}
|
|
158
|
+
>
|
|
159
|
+
<Ruler aria-hidden="true" />
|
|
160
|
+
<span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
|
|
161
|
+
<span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
|
|
162
|
+
</button>
|
|
163
|
+
<PageZoomControl
|
|
164
|
+
scaleMode={pageViewport.scaleMode}
|
|
165
|
+
scaleLabel={pageViewport.scaleLabel}
|
|
166
|
+
pageLayoutMode={pageLayoutMode}
|
|
167
|
+
onScaleModeChange={pageViewport.setScaleMode}
|
|
168
|
+
onPageLayoutModeChange={setPageLayoutMode}
|
|
169
|
+
/>
|
|
170
|
+
<SearchControl
|
|
171
|
+
sourceBlocksByPath={sourceBlocksByPath}
|
|
172
|
+
onSelectPage={selectPublicPage}
|
|
173
|
+
searcher={staticSearcher}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</WorkbenchShell.Toolbar>
|
|
99
177
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
registerPage={reader.registerPage}
|
|
108
|
-
onInternalAnchorNavigate={selectPublicAnchor}
|
|
109
|
-
/>
|
|
110
|
-
</main>
|
|
178
|
+
<WorkbenchShell.LeftPanel>
|
|
179
|
+
<section className="openpress-public-identity" aria-label="文件資訊">
|
|
180
|
+
<strong>
|
|
181
|
+
<span className="openpress-public-title-main">{projectIdentity.name}</span>
|
|
182
|
+
{projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
|
|
183
|
+
</strong>
|
|
184
|
+
{projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
|
|
111
185
|
</section>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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="章節書籤">
|
|
186
|
+
{bookmarks.length > 0 ? (
|
|
187
|
+
<section
|
|
188
|
+
id="openpress-bookmarks"
|
|
189
|
+
className="openpress-panel-section openpress-panel-section--bookmarks"
|
|
190
|
+
aria-label="章節書籤"
|
|
191
|
+
>
|
|
137
192
|
<nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
|
|
138
193
|
<div className="reader-bookmarks-rail" aria-hidden="true" />
|
|
139
194
|
<Bookmarks items={bookmarks} currentPageIndex={reader.currentPageIndex} onSelectPage={selectPublicPage} />
|
|
140
195
|
</nav>
|
|
141
196
|
</section>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
) : null}
|
|
198
|
+
<CurrentPagePanel
|
|
199
|
+
currentPageLabel={reader.currentPageLabel}
|
|
200
|
+
totalPageLabel={reader.totalPageLabel}
|
|
201
|
+
progressPercent={reader.progressPercent}
|
|
202
|
+
title={currentPage?.title || document.meta.title}
|
|
203
|
+
pageLabelPrefix="頁"
|
|
204
|
+
showHeading={false}
|
|
205
|
+
showTitle={false}
|
|
206
|
+
/>
|
|
207
|
+
</WorkbenchShell.LeftPanel>
|
|
208
|
+
|
|
209
|
+
<WorkbenchShell.MainContent>
|
|
210
|
+
<main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
|
|
211
|
+
<PublicPage
|
|
212
|
+
pages={displayPages}
|
|
213
|
+
currentPageIndex={reader.currentPageIndex}
|
|
214
|
+
sourceContainerRef={sourceContainerRef}
|
|
215
|
+
registerPage={reader.registerPage}
|
|
216
|
+
onInternalAnchorNavigate={selectPublicAnchor}
|
|
217
|
+
pageLayoutMode={pageLayoutMode}
|
|
150
218
|
/>
|
|
151
|
-
</
|
|
152
|
-
</
|
|
153
|
-
</
|
|
219
|
+
</main>
|
|
220
|
+
</WorkbenchShell.MainContent>
|
|
221
|
+
</WorkbenchShell>
|
|
154
222
|
);
|
|
155
223
|
}
|
|
156
224
|
|
|
@@ -171,6 +239,30 @@ export function PrintDocument({
|
|
|
171
239
|
const displayPages = pages;
|
|
172
240
|
const registerPage = () => () => undefined;
|
|
173
241
|
|
|
242
|
+
// Mirror the per-document page geometry vars onto :root so the @page
|
|
243
|
+
// rule in print-route.css can resolve them. CSS custom properties set
|
|
244
|
+
// on <main> never reach @page in any browser; without this, headless
|
|
245
|
+
// Chrome falls back to the workspace theme default (210mm × 297mm A4)
|
|
246
|
+
// and slide/social/landscape presses print onto the wrong paper.
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (typeof document === "undefined" || typeof window === "undefined") return undefined;
|
|
249
|
+
const root = window.document.documentElement;
|
|
250
|
+
const overrides: Array<[string, string]> = [];
|
|
251
|
+
for (const [key, value] of Object.entries(style)) {
|
|
252
|
+
if (typeof key === "string" && key.startsWith("--") && typeof value === "string") {
|
|
253
|
+
overrides.push([key, value]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const previous = overrides.map(([key]) => [key, root.style.getPropertyValue(key)] as const);
|
|
257
|
+
overrides.forEach(([key, value]) => root.style.setProperty(key, value));
|
|
258
|
+
return () => {
|
|
259
|
+
previous.forEach(([key, value]) => {
|
|
260
|
+
if (value) root.style.setProperty(key, value);
|
|
261
|
+
else root.style.removeProperty(key);
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
}, [style]);
|
|
265
|
+
|
|
174
266
|
return (
|
|
175
267
|
<main
|
|
176
268
|
className="openpress-print-document"
|
|
@@ -181,7 +273,6 @@ export function PrintDocument({
|
|
|
181
273
|
<PublicPage
|
|
182
274
|
pages={displayPages}
|
|
183
275
|
currentPageIndex={0}
|
|
184
|
-
devMode={false}
|
|
185
276
|
sourceContainerRef={sourceContainerRef}
|
|
186
277
|
registerPage={registerPage}
|
|
187
278
|
exposeSourceData
|
|
@@ -193,7 +284,6 @@ export function PrintDocument({
|
|
|
193
284
|
export function PublicPage({
|
|
194
285
|
pages,
|
|
195
286
|
currentPageIndex,
|
|
196
|
-
devMode,
|
|
197
287
|
sourceContainerRef,
|
|
198
288
|
registerPage,
|
|
199
289
|
exposeSourceData = false,
|
|
@@ -203,7 +293,6 @@ export function PublicPage({
|
|
|
203
293
|
}: {
|
|
204
294
|
pages: DisplayPage[];
|
|
205
295
|
currentPageIndex: number;
|
|
206
|
-
devMode: boolean;
|
|
207
296
|
sourceContainerRef: RefObject<HTMLDivElement | null>;
|
|
208
297
|
registerPage: (pageIndex: number) => RefCallback<HTMLElement>;
|
|
209
298
|
exposeSourceData?: boolean;
|
|
@@ -66,13 +66,18 @@ export function SlidePresentationPage({
|
|
|
66
66
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
67
67
|
if (isEditableTarget(event.target)) return;
|
|
68
68
|
if (event.key === "Escape") {
|
|
69
|
-
|
|
69
|
+
// Esc is reserved for exiting browser fullscreen. The chrome HUD
|
|
70
|
+
// already exposes explicit "re-enter fullscreen" and "close"
|
|
71
|
+
// buttons; navigating out of the presenter from a stray keystroke
|
|
72
|
+
// would yank the user back to the workspace shell unexpectedly
|
|
73
|
+
// (and racily, since the same Esc that triggered the browser's
|
|
74
|
+
// fullscreen exit is also delivered to this handler with
|
|
75
|
+
// fullscreenElement already null).
|
|
70
76
|
const activeDocument = globalThis.document;
|
|
71
77
|
if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
|
|
78
|
+
event.preventDefault();
|
|
72
79
|
void activeDocument.exitFullscreen();
|
|
73
|
-
return;
|
|
74
80
|
}
|
|
75
|
-
onExitPresentation?.(currentPageIndexRef.current);
|
|
76
81
|
return;
|
|
77
82
|
}
|
|
78
83
|
if (event.key === " " || event.code === "Space") {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
export interface UsePanelStateOptions {
|
|
4
4
|
leftPanelBreakpoint?: number;
|
|
@@ -31,26 +31,35 @@ export function usePanelState({
|
|
|
31
31
|
const [rightPanelOpen, setRightPanelOpen] = useState(false);
|
|
32
32
|
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
|
|
33
33
|
|
|
34
|
+
// The auto-close-on-narrow rule is a *resize* response, not a state-change
|
|
35
|
+
// response. Keep current panel state in a ref so the resize listener can read
|
|
36
|
+
// it without re-subscribing every toggle — otherwise toggling a drawer open
|
|
37
|
+
// in a narrow viewport would re-run this effect, call handleResize
|
|
38
|
+
// synchronously, see "open + below breakpoint", and immediately close the
|
|
39
|
+
// panel the user just opened.
|
|
40
|
+
const panelStateRef = useRef({ leftPanelOpen, rightPanelOpen });
|
|
41
|
+
panelStateRef.current = { leftPanelOpen, rightPanelOpen };
|
|
42
|
+
|
|
34
43
|
useEffect(() => {
|
|
35
44
|
if (typeof window === "undefined") return undefined;
|
|
36
45
|
|
|
37
46
|
const handleResize = () => {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
47
|
+
const { leftPanelOpen: lo, rightPanelOpen: ro } = panelStateRef.current;
|
|
48
|
+
const closeLeftPanel = lo && !shouldOpenLeftPanel();
|
|
49
|
+
const closeRightPanel = ro && !shouldOpenRightPanel();
|
|
40
50
|
|
|
41
51
|
if (closeLeftPanel) setLeftPanelOpen(false);
|
|
42
52
|
if (closeRightPanel) setRightPanelOpen(false);
|
|
43
53
|
if (closeLeftPanel || closeRightPanel) onAfterResize?.();
|
|
44
54
|
};
|
|
45
55
|
|
|
46
|
-
handleResize();
|
|
47
56
|
window.addEventListener("resize", handleResize);
|
|
48
57
|
window.visualViewport?.addEventListener("resize", handleResize);
|
|
49
58
|
return () => {
|
|
50
59
|
window.removeEventListener("resize", handleResize);
|
|
51
60
|
window.visualViewport?.removeEventListener("resize", handleResize);
|
|
52
61
|
};
|
|
53
|
-
}, [
|
|
62
|
+
}, [shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
|
|
54
63
|
|
|
55
64
|
const toggleLeftPanel = useCallback(() => setLeftPanelOpen((open) => !open), []);
|
|
56
65
|
const toggleRightPanel = useCallback(() => setRightPanelOpen((open) => !open), []);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Browser-safe literal-substring search over the build-time search
|
|
2
|
+
// corpus (<outputDir>/openpress/search-corpus.json). Mirrors the
|
|
3
|
+
// `searchSourceText` logic in engine/runtime/source-text-tools.mjs so
|
|
4
|
+
// public deploys can search without the /__openpress/search dev endpoint.
|
|
5
|
+
|
|
6
|
+
export type SearchScope = "content" | "all";
|
|
7
|
+
|
|
8
|
+
export interface SearchCorpusFile {
|
|
9
|
+
scope: string;
|
|
10
|
+
file: string;
|
|
11
|
+
path: string;
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SearchCorpus {
|
|
16
|
+
kind: "search-corpus";
|
|
17
|
+
version: number;
|
|
18
|
+
files: SearchCorpusFile[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SearchReportFile {
|
|
22
|
+
scope: string;
|
|
23
|
+
file: string;
|
|
24
|
+
path: string;
|
|
25
|
+
matchCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SearchReportMatch {
|
|
29
|
+
id: string;
|
|
30
|
+
scope: string;
|
|
31
|
+
file: string;
|
|
32
|
+
path: string;
|
|
33
|
+
line: number;
|
|
34
|
+
column: number;
|
|
35
|
+
index: number;
|
|
36
|
+
text: string;
|
|
37
|
+
preview: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SearchReport {
|
|
41
|
+
ok?: boolean;
|
|
42
|
+
kind: "search";
|
|
43
|
+
query: string;
|
|
44
|
+
scope: SearchScope;
|
|
45
|
+
caseSensitive: boolean;
|
|
46
|
+
matchCount: number;
|
|
47
|
+
files: SearchReportFile[];
|
|
48
|
+
matches: SearchReportMatch[];
|
|
49
|
+
message?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SearchCorpusQueryOptions {
|
|
53
|
+
query: string;
|
|
54
|
+
scope?: SearchScope;
|
|
55
|
+
caseSensitive?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function searchCorpus(corpus: SearchCorpus, options: SearchCorpusQueryOptions): SearchReport {
|
|
59
|
+
const query = options.query;
|
|
60
|
+
const scope: SearchScope = options.scope ?? "content";
|
|
61
|
+
const caseSensitive = options.caseSensitive ?? false;
|
|
62
|
+
const matches: SearchReportMatch[] = [];
|
|
63
|
+
|
|
64
|
+
if (!query) {
|
|
65
|
+
return { kind: "search", query, scope, caseSensitive, matchCount: 0, files: [], matches: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const file of corpus.files) {
|
|
69
|
+
const rawMatches = findLiteralMatches(file.text, query, { caseSensitive });
|
|
70
|
+
for (const match of rawMatches) {
|
|
71
|
+
matches.push({
|
|
72
|
+
id: `match-${String(matches.length + 1).padStart(4, "0")}`,
|
|
73
|
+
scope: file.scope,
|
|
74
|
+
file: file.file,
|
|
75
|
+
path: file.path,
|
|
76
|
+
line: match.line,
|
|
77
|
+
column: match.column,
|
|
78
|
+
index: match.index,
|
|
79
|
+
text: match.text,
|
|
80
|
+
preview: match.preview,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
kind: "search",
|
|
87
|
+
query,
|
|
88
|
+
scope,
|
|
89
|
+
caseSensitive,
|
|
90
|
+
matchCount: matches.length,
|
|
91
|
+
files: summarizeFiles(matches),
|
|
92
|
+
matches,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface RawMatch {
|
|
97
|
+
line: number;
|
|
98
|
+
column: number;
|
|
99
|
+
index: number;
|
|
100
|
+
text: string;
|
|
101
|
+
preview: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findLiteralMatches(text: string, query: string, options: { caseSensitive: boolean }): RawMatch[] {
|
|
105
|
+
if (!query) return [];
|
|
106
|
+
const matches: RawMatch[] = [];
|
|
107
|
+
forEachLine(text, ({ line, lineNumber, lineOffset }) => {
|
|
108
|
+
for (const range of findLineMatches(line, query, options)) {
|
|
109
|
+
matches.push({
|
|
110
|
+
line: lineNumber,
|
|
111
|
+
column: range.start + 1,
|
|
112
|
+
index: lineOffset + range.start,
|
|
113
|
+
text: line.slice(range.start, range.end),
|
|
114
|
+
preview: previewLine(line, range.start, range.end),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return matches;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findLineMatches(line: string, query: string, { caseSensitive }: { caseSensitive: boolean }) {
|
|
122
|
+
const haystack = caseSensitive ? line : line.toLowerCase();
|
|
123
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
124
|
+
const ranges: { start: number; end: number }[] = [];
|
|
125
|
+
let cursor = 0;
|
|
126
|
+
while (needle && cursor <= haystack.length) {
|
|
127
|
+
const start = haystack.indexOf(needle, cursor);
|
|
128
|
+
if (start < 0) break;
|
|
129
|
+
const end = start + needle.length;
|
|
130
|
+
ranges.push({ start, end });
|
|
131
|
+
cursor = end;
|
|
132
|
+
}
|
|
133
|
+
return ranges;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function previewLine(line: string, start: number, end: number) {
|
|
137
|
+
const previewStart = Math.max(0, start - 40);
|
|
138
|
+
const previewEnd = Math.min(line.length, end + 40);
|
|
139
|
+
const prefix = previewStart > 0 ? "..." : "";
|
|
140
|
+
const suffix = previewEnd < line.length ? "..." : "";
|
|
141
|
+
return `${prefix}${line.slice(previewStart, previewEnd)}${suffix}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function forEachLine(
|
|
145
|
+
text: string,
|
|
146
|
+
visit: (info: { line: string; ending: string; lineNumber: number; lineOffset: number }) => void,
|
|
147
|
+
) {
|
|
148
|
+
const lineRe = /([^\r\n]*)(\r\n|\n|\r|$)/g;
|
|
149
|
+
let lineNumber = 1;
|
|
150
|
+
let offset = 0;
|
|
151
|
+
let match: RegExpExecArray | null;
|
|
152
|
+
while ((match = lineRe.exec(text))) {
|
|
153
|
+
const [full, line, ending] = match;
|
|
154
|
+
if (full === "") break;
|
|
155
|
+
visit({ line, ending, lineNumber, lineOffset: offset });
|
|
156
|
+
offset += full.length;
|
|
157
|
+
lineNumber += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function summarizeFiles(matches: SearchReportMatch[]): SearchReportFile[] {
|
|
162
|
+
const grouped = new Map<string, SearchReportFile>();
|
|
163
|
+
for (const match of matches) {
|
|
164
|
+
const current = grouped.get(match.path) ?? {
|
|
165
|
+
scope: match.scope,
|
|
166
|
+
file: match.file,
|
|
167
|
+
path: match.path,
|
|
168
|
+
matchCount: 0,
|
|
169
|
+
};
|
|
170
|
+
current.matchCount += 1;
|
|
171
|
+
grouped.set(match.path, current);
|
|
172
|
+
}
|
|
173
|
+
return Array.from(grouped.values());
|
|
174
|
+
}
|