@open-press/core 0.7.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/engine/cli.mjs +8 -8
- package/engine/commands/_shared.mjs +37 -15
- package/engine/commands/dev.mjs +2 -2
- package/engine/commands/image.mjs +29 -0
- package/engine/commands/skills-sync.mjs +71 -0
- package/engine/commands/typecheck.mjs +63 -1
- package/engine/commands/upgrade.mjs +3 -3
- package/engine/document-export.mjs +1 -1
- package/engine/output/chrome-pdf.mjs +110 -3
- package/engine/output/static-server.mjs +87 -9
- package/engine/react/comment-endpoint.mjs +13 -39
- package/engine/react/comment-marker.mjs +43 -19
- package/engine/react/document-entry.mjs +46 -28
- package/engine/react/document-export.mjs +328 -164
- package/engine/react/http-json.mjs +24 -0
- package/engine/react/mdx-compile.mjs +126 -3
- package/engine/react/measurement-css.mjs +114 -1
- package/engine/react/object-entities.mjs +204 -0
- package/engine/react/pagination/allocator.mjs +48 -3
- package/engine/react/pagination.mjs +1 -1
- package/engine/react/pipeline/allocate.mjs +41 -72
- package/engine/react/pipeline/frame-measurement.mjs +6 -0
- package/engine/react/press-tree-inspection.mjs +172 -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 +13 -15
- package/engine/react/style-discovery.mjs +23 -8
- package/engine/runtime/config.d.mts +8 -0
- package/engine/runtime/config.mjs +57 -60
- package/engine/runtime/file-utils.mjs +9 -1
- package/engine/runtime/file-walk.mjs +22 -0
- package/engine/runtime/inspection.mjs +1 -20
- package/engine/runtime/page-geometry.mjs +131 -0
- 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 +16 -34
- package/engine/runtime/validation.mjs +19 -10
- package/package.json +3 -5
- package/src/openpress/app/OpenPressApp.tsx +296 -0
- package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
- package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
- package/src/openpress/app/index.ts +2 -0
- package/src/openpress/core/Frame.tsx +26 -15
- package/src/openpress/core/FrameContext.tsx +10 -3
- package/src/openpress/core/MdxArea.tsx +11 -12
- package/src/openpress/core/Press.tsx +25 -4
- package/src/openpress/core/Workspace.tsx +36 -0
- package/src/openpress/core/cn.ts +4 -0
- package/src/openpress/core/index.tsx +11 -3
- package/src/openpress/core/primitives.tsx +74 -6
- package/src/openpress/core/types.ts +94 -41
- package/src/openpress/core/useSource.ts +1 -1
- 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} +51 -0
- package/src/openpress/document-model/index.ts +7 -0
- package/src/openpress/document-model/objectEntityModel.ts +55 -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/document-model/workspaceManifestModel.ts +57 -0
- package/src/openpress/manuscript/index.tsx +49 -7
- package/src/openpress/mdx/index.ts +15 -7
- package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
- 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 +11 -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 +506 -0
- package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
- package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -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 +6 -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 +254 -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 +80 -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 +525 -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 +252 -55
- package/src/styles/openpress/responsive.css +145 -270
- package/src/styles/openpress/workbench-panels.css +327 -178
- package/src/styles/openpress/workbench.css +986 -451
- package/src/styles/openpress/workspace-gallery.css +300 -0
- package/src/styles/openpress.css +2 -1
- package/tsconfig.json +1 -1
- package/vite.config.ts +50 -0
- package/engine/commands/init.mjs +0 -24
- package/engine/init.mjs +0 -90
- package/src/openpress/App.tsx +0 -127
- 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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { Camera } from "lucide-react";
|
|
3
|
+
import { toPng } from "html-to-image";
|
|
4
|
+
|
|
5
|
+
type ExportStatus = "idle" | "exporting" | "done" | "error";
|
|
6
|
+
|
|
7
|
+
// Exports the currently visible page as a PNG. Locates the page DOM via
|
|
8
|
+
// the data-openpress-page-index attribute (set in PublicReaderPage) and
|
|
9
|
+
// hands it to html-to-image, then triggers a browser download.
|
|
10
|
+
//
|
|
11
|
+
// Lives in the workbench toolbar so it's reachable for any Press shape
|
|
12
|
+
// (manuscript / canvas / slide); for multi-page Press the user navigates
|
|
13
|
+
// to the page first, then exports.
|
|
14
|
+
export function ExportImageControl({
|
|
15
|
+
currentPageIndex,
|
|
16
|
+
currentPageLabel,
|
|
17
|
+
pressTitle,
|
|
18
|
+
}: {
|
|
19
|
+
currentPageIndex: number;
|
|
20
|
+
currentPageLabel: string;
|
|
21
|
+
pressTitle: string;
|
|
22
|
+
}) {
|
|
23
|
+
const [status, setStatus] = useState<ExportStatus>("idle");
|
|
24
|
+
|
|
25
|
+
const handleExport = useCallback(async () => {
|
|
26
|
+
if (status === "exporting") return;
|
|
27
|
+
setStatus("exporting");
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const pageEl = typeof window === "undefined"
|
|
31
|
+
? null
|
|
32
|
+
: window.document.querySelector<HTMLElement>(
|
|
33
|
+
`[data-openpress-page-index="${currentPageIndex}"]`,
|
|
34
|
+
);
|
|
35
|
+
if (!pageEl) throw new Error("找不到目前頁面");
|
|
36
|
+
|
|
37
|
+
// pixelRatio: 2 — retina-ish; keeps text crisp without blowing the file size.
|
|
38
|
+
// cacheBust: true — force re-fetch of images so stale CORS doesn't taint the canvas.
|
|
39
|
+
const dataUrl = await toPng(pageEl, {
|
|
40
|
+
pixelRatio: 2,
|
|
41
|
+
cacheBust: true,
|
|
42
|
+
backgroundColor: "#ffffff",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const safeTitle = sanitizeFilename(pressTitle) || "openpress";
|
|
46
|
+
const safePage = sanitizeFilename(currentPageLabel) || String(currentPageIndex + 1);
|
|
47
|
+
const link = window.document.createElement("a");
|
|
48
|
+
link.href = dataUrl;
|
|
49
|
+
link.download = `${safeTitle}-${safePage}.png`;
|
|
50
|
+
window.document.body.appendChild(link);
|
|
51
|
+
link.click();
|
|
52
|
+
link.remove();
|
|
53
|
+
|
|
54
|
+
setStatus("done");
|
|
55
|
+
window.setTimeout(() => setStatus("idle"), 1600);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("[openpress] page PNG export failed", error);
|
|
58
|
+
setStatus("error");
|
|
59
|
+
window.setTimeout(() => setStatus("idle"), 2400);
|
|
60
|
+
}
|
|
61
|
+
}, [currentPageIndex, currentPageLabel, pressTitle, status]);
|
|
62
|
+
|
|
63
|
+
const label = status === "exporting"
|
|
64
|
+
? "匯出中…"
|
|
65
|
+
: status === "done"
|
|
66
|
+
? "已下載"
|
|
67
|
+
: status === "error"
|
|
68
|
+
? "匯出失敗"
|
|
69
|
+
: "PNG";
|
|
70
|
+
const title = "將目前頁面匯出為 PNG";
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="openpress-workbench-toolbar-action"
|
|
76
|
+
data-openpress-page-png-export
|
|
77
|
+
data-openpress-export-status={status}
|
|
78
|
+
disabled={status === "exporting"}
|
|
79
|
+
onClick={handleExport}
|
|
80
|
+
title={title}
|
|
81
|
+
aria-label={title}
|
|
82
|
+
>
|
|
83
|
+
<Camera aria-hidden="true" />
|
|
84
|
+
<span className="openpress-workbench-toolbar-action__label">{label}</span>
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sanitizeFilename(value: string): string {
|
|
90
|
+
return value
|
|
91
|
+
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
92
|
+
.replace(/\s+/g, "-")
|
|
93
|
+
.replace(/-+/g, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "")
|
|
95
|
+
.slice(0, 80);
|
|
96
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Check, ChevronDown, Columns2, File, ZoomIn } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
PAGE_VIEWPORT_SCALE_OPTIONS,
|
|
6
|
+
type PageLayoutMode,
|
|
7
|
+
type PageViewportScaleMode,
|
|
8
|
+
} from "../../reader";
|
|
9
|
+
|
|
10
|
+
export function PageZoomControl({
|
|
11
|
+
scaleMode,
|
|
12
|
+
scaleLabel,
|
|
13
|
+
pageLayoutMode,
|
|
14
|
+
onScaleModeChange,
|
|
15
|
+
onPageLayoutModeChange,
|
|
16
|
+
}: {
|
|
17
|
+
scaleMode: PageViewportScaleMode;
|
|
18
|
+
scaleLabel: string;
|
|
19
|
+
pageLayoutMode: PageLayoutMode;
|
|
20
|
+
onScaleModeChange: (mode: PageViewportScaleMode) => void;
|
|
21
|
+
onPageLayoutModeChange: (mode: PageLayoutMode) => void;
|
|
22
|
+
}) {
|
|
23
|
+
const menuId = useId();
|
|
24
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
25
|
+
const [open, setOpen] = useState(false);
|
|
26
|
+
const fixedOptions = PAGE_VIEWPORT_SCALE_OPTIONS.filter((option) => option.value.startsWith("scale-"));
|
|
27
|
+
const fitOptions = PAGE_VIEWPORT_SCALE_OPTIONS.filter((option) => option.value.startsWith("fit-"));
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return undefined;
|
|
31
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
32
|
+
if (event.target instanceof Node && rootRef.current?.contains(event.target)) return;
|
|
33
|
+
setOpen(false);
|
|
34
|
+
};
|
|
35
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
36
|
+
if (event.key === "Escape") setOpen(false);
|
|
37
|
+
};
|
|
38
|
+
window.addEventListener("pointerdown", handlePointerDown);
|
|
39
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
40
|
+
return () => {
|
|
41
|
+
window.removeEventListener("pointerdown", handlePointerDown);
|
|
42
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
43
|
+
};
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
const selectScale = (mode: PageViewportScaleMode) => {
|
|
47
|
+
onScaleModeChange(mode);
|
|
48
|
+
setOpen(false);
|
|
49
|
+
};
|
|
50
|
+
const selectLayout = (mode: PageLayoutMode) => {
|
|
51
|
+
onPageLayoutModeChange(mode);
|
|
52
|
+
setOpen(false);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="openpress-workbench-zoom-control-wrap" ref={rootRef} data-openpress-page-zoom-control>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="openpress-workbench-zoom-control"
|
|
60
|
+
data-openpress-page-zoom
|
|
61
|
+
data-openpress-scale-mode={scaleMode}
|
|
62
|
+
data-openpress-toolbar-active={scaleMode === "fit-width" ? "false" : "true"}
|
|
63
|
+
title={`頁面縮放 ${scaleLabel}`}
|
|
64
|
+
aria-label={`頁面縮放 ${scaleLabel}`}
|
|
65
|
+
aria-haspopup="menu"
|
|
66
|
+
aria-expanded={open}
|
|
67
|
+
aria-controls={open ? menuId : undefined}
|
|
68
|
+
onClick={() => setOpen((value) => !value)}
|
|
69
|
+
>
|
|
70
|
+
<ZoomIn aria-hidden="true" />
|
|
71
|
+
<span>{scaleLabel}</span>
|
|
72
|
+
<ChevronDown className="openpress-workbench-zoom-control__chevron" aria-hidden="true" />
|
|
73
|
+
</button>
|
|
74
|
+
{open ? (
|
|
75
|
+
<div
|
|
76
|
+
id={menuId}
|
|
77
|
+
className="openpress-workbench-zoom-menu"
|
|
78
|
+
data-openpress-page-zoom-menu
|
|
79
|
+
role="menu"
|
|
80
|
+
aria-label="頁面顯示與縮放"
|
|
81
|
+
>
|
|
82
|
+
<div className="openpress-workbench-zoom-menu__section" role="group" aria-label="頁面模式">
|
|
83
|
+
<PageLayoutOption
|
|
84
|
+
mode="single"
|
|
85
|
+
active={pageLayoutMode === "single"}
|
|
86
|
+
icon={<File aria-hidden="true" />}
|
|
87
|
+
label="一頁"
|
|
88
|
+
onSelect={selectLayout}
|
|
89
|
+
/>
|
|
90
|
+
<PageLayoutOption
|
|
91
|
+
mode="spread"
|
|
92
|
+
active={pageLayoutMode === "spread"}
|
|
93
|
+
icon={<Columns2 aria-hidden="true" />}
|
|
94
|
+
label="雙頁"
|
|
95
|
+
onSelect={selectLayout}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="openpress-workbench-zoom-menu__divider" role="presentation" />
|
|
99
|
+
<div className="openpress-workbench-zoom-menu__section" role="group" aria-label="固定縮放">
|
|
100
|
+
{fixedOptions.map((option) => (
|
|
101
|
+
<ZoomOption
|
|
102
|
+
key={option.value}
|
|
103
|
+
mode={option.value}
|
|
104
|
+
active={scaleMode === option.value}
|
|
105
|
+
label={option.label}
|
|
106
|
+
onSelect={selectScale}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
<div className="openpress-workbench-zoom-menu__divider" role="presentation" />
|
|
111
|
+
<div className="openpress-workbench-zoom-menu__section" role="group" aria-label="符合顯示">
|
|
112
|
+
{fitOptions.map((option) => (
|
|
113
|
+
<ZoomOption
|
|
114
|
+
key={option.value}
|
|
115
|
+
mode={option.value}
|
|
116
|
+
active={scaleMode === option.value}
|
|
117
|
+
label={option.label}
|
|
118
|
+
onSelect={selectScale}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function PageLayoutOption({
|
|
129
|
+
mode,
|
|
130
|
+
active,
|
|
131
|
+
icon,
|
|
132
|
+
label,
|
|
133
|
+
onSelect,
|
|
134
|
+
}: {
|
|
135
|
+
mode: PageLayoutMode;
|
|
136
|
+
active: boolean;
|
|
137
|
+
icon: ReactNode;
|
|
138
|
+
label: string;
|
|
139
|
+
onSelect: (mode: PageLayoutMode) => void;
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className="openpress-workbench-zoom-menu__item"
|
|
145
|
+
data-openpress-page-layout-option={mode}
|
|
146
|
+
role="menuitemcheckbox"
|
|
147
|
+
aria-checked={active}
|
|
148
|
+
onClick={() => onSelect(mode)}
|
|
149
|
+
>
|
|
150
|
+
<span className="openpress-workbench-zoom-menu__check">{active ? <Check aria-hidden="true" /> : null}</span>
|
|
151
|
+
{icon}
|
|
152
|
+
<span>{label}</span>
|
|
153
|
+
</button>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ZoomOption({
|
|
158
|
+
mode,
|
|
159
|
+
active,
|
|
160
|
+
label,
|
|
161
|
+
onSelect,
|
|
162
|
+
}: {
|
|
163
|
+
mode: PageViewportScaleMode;
|
|
164
|
+
active: boolean;
|
|
165
|
+
label: string;
|
|
166
|
+
onSelect: (mode: PageViewportScaleMode) => void;
|
|
167
|
+
}) {
|
|
168
|
+
return (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className="openpress-workbench-zoom-menu__item"
|
|
172
|
+
data-openpress-zoom-option={mode}
|
|
173
|
+
role="menuitemradio"
|
|
174
|
+
aria-checked={active}
|
|
175
|
+
onClick={() => onSelect(mode)}
|
|
176
|
+
>
|
|
177
|
+
<span className="openpress-workbench-zoom-menu__check">{active ? <Check aria-hidden="true" /> : null}</span>
|
|
178
|
+
<span className="openpress-workbench-zoom-menu__spacer" aria-hidden="true" />
|
|
179
|
+
<span>{label}</span>
|
|
180
|
+
</button>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { FileText, Loader2, Search } from "lucide-react";
|
|
3
|
+
import type { SourceBlock } from "../../document-model";
|
|
4
|
+
import { WorkbenchDialog } from "../dialog";
|
|
5
|
+
|
|
6
|
+
type SearchScope = "content" | "all";
|
|
7
|
+
type SearchStatus = "idle" | "loading" | "success" | "error";
|
|
8
|
+
const SEARCH_SCOPE: SearchScope = "all";
|
|
9
|
+
const LIVE_SEARCH_DEBOUNCE_MS = 280;
|
|
10
|
+
|
|
11
|
+
type SearchFile = {
|
|
12
|
+
scope: string;
|
|
13
|
+
file: string;
|
|
14
|
+
path: string;
|
|
15
|
+
matchCount: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type SearchMatch = {
|
|
19
|
+
id: string;
|
|
20
|
+
scope: string;
|
|
21
|
+
file: string;
|
|
22
|
+
path: string;
|
|
23
|
+
line: number;
|
|
24
|
+
column: number;
|
|
25
|
+
index: number;
|
|
26
|
+
text: string;
|
|
27
|
+
preview: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type SearchReport = {
|
|
31
|
+
ok?: boolean;
|
|
32
|
+
kind: "search";
|
|
33
|
+
query: string;
|
|
34
|
+
scope: SearchScope;
|
|
35
|
+
caseSensitive: boolean;
|
|
36
|
+
matchCount: number;
|
|
37
|
+
files: Array<SearchFile>;
|
|
38
|
+
matches: Array<SearchMatch>;
|
|
39
|
+
message?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type SearchJumpTarget = {
|
|
43
|
+
blockId: string;
|
|
44
|
+
pageIndex: number;
|
|
45
|
+
pageNumber: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function SearchControl({
|
|
49
|
+
sourceBlocksByPath = {},
|
|
50
|
+
onSelectPage,
|
|
51
|
+
}: {
|
|
52
|
+
sourceBlocksByPath?: Record<string, SourceBlock[]>;
|
|
53
|
+
onSelectPage?: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
|
|
54
|
+
}) {
|
|
55
|
+
const titleId = useId();
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const [query, setQuery] = useState("");
|
|
58
|
+
const [status, setStatus] = useState<SearchStatus>("idle");
|
|
59
|
+
const [report, setReport] = useState<SearchReport | null>(null);
|
|
60
|
+
const [error, setError] = useState("");
|
|
61
|
+
const activeSearchControllerRef = useRef<AbortController | null>(null);
|
|
62
|
+
const searchRequestIdRef = useRef(0);
|
|
63
|
+
const submittedQueryRef = useRef<string | null>(null);
|
|
64
|
+
const matchesByPath = useMemo(() => groupMatchesByPath(report?.matches ?? []), [report]);
|
|
65
|
+
|
|
66
|
+
const jumpToMatch = (match: SearchMatch) => {
|
|
67
|
+
const target = resolveSearchJumpTarget(match, sourceBlocksByPath);
|
|
68
|
+
if (!target || !onSelectPage) return;
|
|
69
|
+
onSelectPage(target.pageIndex, { behavior: "smooth" });
|
|
70
|
+
setOpen(false);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return;
|
|
75
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
76
|
+
if (event.key === "Escape") setOpen(false);
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
79
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
80
|
+
}, [open]);
|
|
81
|
+
|
|
82
|
+
const runSearch = useCallback(async (rawQuery: string, controller: AbortController) => {
|
|
83
|
+
const trimmedQuery = rawQuery.trim();
|
|
84
|
+
if (!trimmedQuery) {
|
|
85
|
+
activeSearchControllerRef.current?.abort();
|
|
86
|
+
activeSearchControllerRef.current = null;
|
|
87
|
+
setReport(null);
|
|
88
|
+
setError("");
|
|
89
|
+
setStatus("idle");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
activeSearchControllerRef.current?.abort();
|
|
94
|
+
activeSearchControllerRef.current = controller;
|
|
95
|
+
const requestId = searchRequestIdRef.current + 1;
|
|
96
|
+
searchRequestIdRef.current = requestId;
|
|
97
|
+
setStatus("loading");
|
|
98
|
+
setError("");
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const params = new URLSearchParams();
|
|
102
|
+
params.set("q", trimmedQuery);
|
|
103
|
+
params.set("scope", SEARCH_SCOPE);
|
|
104
|
+
const response = await fetch(`/__openpress/search?${params.toString()}`, {
|
|
105
|
+
cache: "no-store",
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
});
|
|
108
|
+
const data = await response.json().catch(() => null) as (Partial<SearchReport> & { message?: string }) | null;
|
|
109
|
+
if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
|
|
110
|
+
if (!response.ok || data?.ok === false || !isSearchReport(data)) {
|
|
111
|
+
throw new Error(data?.message ?? "搜尋失敗。");
|
|
112
|
+
}
|
|
113
|
+
setReport(data);
|
|
114
|
+
setStatus("success");
|
|
115
|
+
} catch (searchError) {
|
|
116
|
+
if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
|
|
117
|
+
setError(searchError instanceof Error ? searchError.message : String(searchError));
|
|
118
|
+
setStatus("error");
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!open) return undefined;
|
|
124
|
+
const trimmedQuery = query.trim();
|
|
125
|
+
if (!trimmedQuery) {
|
|
126
|
+
activeSearchControllerRef.current?.abort();
|
|
127
|
+
activeSearchControllerRef.current = null;
|
|
128
|
+
setReport(null);
|
|
129
|
+
setError("");
|
|
130
|
+
setStatus("idle");
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = window.setTimeout(() => {
|
|
136
|
+
if (submittedQueryRef.current === trimmedQuery) {
|
|
137
|
+
submittedQueryRef.current = null;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
void runSearch(trimmedQuery, controller);
|
|
141
|
+
}, LIVE_SEARCH_DEBOUNCE_MS);
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
window.clearTimeout(timer);
|
|
145
|
+
controller.abort();
|
|
146
|
+
};
|
|
147
|
+
}, [open, query, runSearch]);
|
|
148
|
+
|
|
149
|
+
const submitSearch = async (event: FormEvent<HTMLFormElement>) => {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
const trimmedQuery = query.trim();
|
|
152
|
+
submittedQueryRef.current = trimmedQuery;
|
|
153
|
+
await runSearch(trimmedQuery, new AbortController());
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const dialog = open ? (
|
|
157
|
+
<WorkbenchDialog
|
|
158
|
+
titleId={titleId}
|
|
159
|
+
title="搜尋文件"
|
|
160
|
+
eyebrow="Search"
|
|
161
|
+
className="openpress-search-dialog"
|
|
162
|
+
backdropClassName="openpress-search-dialog-backdrop"
|
|
163
|
+
headerClassName="openpress-search-dialog__header"
|
|
164
|
+
closeLabel="關閉搜尋"
|
|
165
|
+
onClose={() => setOpen(false)}
|
|
166
|
+
>
|
|
167
|
+
<form className="openpress-search-dialog__form" role="search" aria-label="文件搜尋" onSubmit={submitSearch}>
|
|
168
|
+
<div className="openpress-search-dialog__input-row">
|
|
169
|
+
<Search aria-hidden="true" />
|
|
170
|
+
<input
|
|
171
|
+
type="search"
|
|
172
|
+
value={query}
|
|
173
|
+
autoFocus
|
|
174
|
+
aria-label="搜尋文字"
|
|
175
|
+
placeholder="搜尋所有來源"
|
|
176
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
177
|
+
/>
|
|
178
|
+
<button type="submit" disabled={status === "loading"}>
|
|
179
|
+
{status === "loading" ? <Loader2 aria-hidden="true" /> : <Search aria-hidden="true" />}
|
|
180
|
+
<span>搜尋</span>
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</form>
|
|
184
|
+
<SearchResults
|
|
185
|
+
status={status}
|
|
186
|
+
report={report}
|
|
187
|
+
error={error}
|
|
188
|
+
matchesByPath={matchesByPath}
|
|
189
|
+
sourceBlocksByPath={sourceBlocksByPath}
|
|
190
|
+
onJumpToMatch={jumpToMatch}
|
|
191
|
+
/>
|
|
192
|
+
</WorkbenchDialog>
|
|
193
|
+
) : null;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className="openpress-workbench-toolbar-action"
|
|
200
|
+
data-openpress-search
|
|
201
|
+
data-openpress-toolbar-expanded="false"
|
|
202
|
+
data-openpress-toolbar-active={open ? "true" : "false"}
|
|
203
|
+
aria-label="搜尋文件"
|
|
204
|
+
title="搜尋文件"
|
|
205
|
+
onClick={() => setOpen(true)}
|
|
206
|
+
>
|
|
207
|
+
<Search aria-hidden="true" />
|
|
208
|
+
</button>
|
|
209
|
+
{dialog}
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function SearchResults({
|
|
215
|
+
status,
|
|
216
|
+
report,
|
|
217
|
+
error,
|
|
218
|
+
matchesByPath,
|
|
219
|
+
sourceBlocksByPath,
|
|
220
|
+
onJumpToMatch,
|
|
221
|
+
}: {
|
|
222
|
+
status: SearchStatus;
|
|
223
|
+
report: SearchReport | null;
|
|
224
|
+
error: string;
|
|
225
|
+
matchesByPath: Map<string, Array<SearchMatch>>;
|
|
226
|
+
sourceBlocksByPath: Record<string, SourceBlock[]>;
|
|
227
|
+
onJumpToMatch: (match: SearchMatch) => void;
|
|
228
|
+
}) {
|
|
229
|
+
if (status === "idle") {
|
|
230
|
+
return <p className="openpress-search-dialog__empty">輸入關鍵字即可搜尋文件內容、元件與設定來源。</p>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (status === "loading") {
|
|
234
|
+
return (
|
|
235
|
+
<p className="openpress-search-dialog__empty" role="status">
|
|
236
|
+
<Loader2 aria-hidden="true" />
|
|
237
|
+
<span>搜尋中</span>
|
|
238
|
+
</p>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (status === "error") {
|
|
243
|
+
return <p className="openpress-search-dialog__error" role="alert">{error || "搜尋失敗。"}</p>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!report || report.matchCount === 0) {
|
|
247
|
+
return <p className="openpress-search-dialog__empty">沒有符合的來源文字。</p>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="openpress-search-dialog__results" aria-live="polite">
|
|
252
|
+
<p className="openpress-search-dialog__summary">{report.matchCount} 個符合結果</p>
|
|
253
|
+
{report.files.map((file) => (
|
|
254
|
+
<section className="openpress-search-dialog__file" key={file.path}>
|
|
255
|
+
<h3>
|
|
256
|
+
<FileText aria-hidden="true" />
|
|
257
|
+
<span>{file.path}</span>
|
|
258
|
+
<small>{file.matchCount}</small>
|
|
259
|
+
</h3>
|
|
260
|
+
<ol>
|
|
261
|
+
{(matchesByPath.get(file.path) ?? []).map((match) => {
|
|
262
|
+
const jumpTarget = resolveSearchJumpTarget(match, sourceBlocksByPath);
|
|
263
|
+
return (
|
|
264
|
+
<li key={match.id}>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className="openpress-search-dialog__result"
|
|
268
|
+
data-openpress-search-result-jump={jumpTarget ? "true" : "false"}
|
|
269
|
+
disabled={!jumpTarget}
|
|
270
|
+
onClick={() => onJumpToMatch(match)}
|
|
271
|
+
>
|
|
272
|
+
<span className="openpress-search-dialog__line">{match.line}:{match.column}</span>
|
|
273
|
+
<span className="openpress-search-dialog__preview">{match.preview}</span>
|
|
274
|
+
<span className="openpress-search-dialog__page">
|
|
275
|
+
{jumpTarget ? `P${String(jumpTarget.pageNumber).padStart(2, "0")}` : "source"}
|
|
276
|
+
</span>
|
|
277
|
+
</button>
|
|
278
|
+
</li>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
281
|
+
</ol>
|
|
282
|
+
</section>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function groupMatchesByPath(matches: Array<SearchMatch>) {
|
|
289
|
+
const grouped = new Map<string, Array<SearchMatch>>();
|
|
290
|
+
for (const match of matches) {
|
|
291
|
+
const existing = grouped.get(match.path) ?? [];
|
|
292
|
+
existing.push(match);
|
|
293
|
+
grouped.set(match.path, existing);
|
|
294
|
+
}
|
|
295
|
+
return grouped;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isSearchReport(value: unknown): value is SearchReport {
|
|
299
|
+
if (!value || typeof value !== "object") return false;
|
|
300
|
+
const report = value as Partial<SearchReport>;
|
|
301
|
+
return report.kind === "search"
|
|
302
|
+
&& typeof report.query === "string"
|
|
303
|
+
&& typeof report.matchCount === "number"
|
|
304
|
+
&& Array.isArray(report.files)
|
|
305
|
+
&& Array.isArray(report.matches);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function resolveSearchJumpTarget(
|
|
309
|
+
match: SearchMatch,
|
|
310
|
+
sourceBlocksByPath: Record<string, SourceBlock[]>,
|
|
311
|
+
): SearchJumpTarget | null {
|
|
312
|
+
const blocks = sourcePathKeys(match.path)
|
|
313
|
+
.flatMap((key) => sourceBlocksByPath[key] ?? []);
|
|
314
|
+
if (!blocks.length) return null;
|
|
315
|
+
|
|
316
|
+
let selectedBlock: SourceBlock | null = null;
|
|
317
|
+
for (const block of blocks) {
|
|
318
|
+
const line = block.source?.line;
|
|
319
|
+
if (typeof line !== "number") continue;
|
|
320
|
+
if (line <= match.line) {
|
|
321
|
+
selectedBlock = block;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const targetBlock = selectedBlock ?? blocks[0] ?? null;
|
|
328
|
+
if (!targetBlock || typeof targetBlock.pageIndex !== "number") return null;
|
|
329
|
+
return {
|
|
330
|
+
blockId: targetBlock.id,
|
|
331
|
+
pageIndex: targetBlock.pageIndex,
|
|
332
|
+
pageNumber: targetBlock.pageNumber ?? targetBlock.pageIndex + 1,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function sourcePathKeys(value: string) {
|
|
337
|
+
const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
|
338
|
+
const keys = [normalized];
|
|
339
|
+
if (normalized.startsWith("press/")) {
|
|
340
|
+
keys.push(normalized.replace(/^press\//, ""));
|
|
341
|
+
} else {
|
|
342
|
+
keys.push(`press/${normalized}`);
|
|
343
|
+
}
|
|
344
|
+
return Array.from(new Set(keys));
|
|
345
|
+
}
|