@open-press/cli 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.
Files changed (234) hide show
  1. package/README.md +29 -13
  2. package/dist/cli.js +44 -195
  3. package/package.json +4 -5
  4. package/template/core/AGENTS.md +18 -14
  5. package/template/core/CHANGELOG.md +57 -9
  6. package/template/core/README.md +6 -3
  7. package/template/core/engine/cli.mjs +8 -8
  8. package/template/core/engine/commands/_shared.mjs +37 -15
  9. package/template/core/engine/commands/dev.mjs +2 -2
  10. package/template/core/engine/commands/image.mjs +29 -0
  11. package/template/core/engine/commands/skills-sync.mjs +71 -0
  12. package/template/core/engine/commands/typecheck.mjs +63 -1
  13. package/template/core/engine/commands/upgrade.mjs +3 -3
  14. package/template/core/engine/document-export.mjs +1 -1
  15. package/template/core/engine/output/chrome-pdf.mjs +110 -3
  16. package/template/core/engine/output/static-server.mjs +87 -9
  17. package/template/core/engine/react/comment-endpoint.mjs +13 -39
  18. package/template/core/engine/react/comment-marker.mjs +43 -19
  19. package/template/core/engine/react/document-entry.mjs +46 -28
  20. package/template/core/engine/react/document-export.mjs +328 -164
  21. package/template/core/engine/react/http-json.mjs +24 -0
  22. package/template/core/engine/react/mdx-compile.mjs +126 -3
  23. package/template/core/engine/react/measurement-css.mjs +114 -1
  24. package/template/core/engine/react/object-entities.mjs +204 -0
  25. package/template/core/engine/react/pagination/allocator.mjs +48 -3
  26. package/template/core/engine/react/pagination.mjs +1 -1
  27. package/template/core/engine/react/pipeline/allocate.mjs +41 -72
  28. package/template/core/engine/react/pipeline/frame-measurement.mjs +6 -0
  29. package/template/core/engine/react/press-tree-inspection.mjs +172 -0
  30. package/template/core/engine/react/project-asset-endpoint.mjs +6 -24
  31. package/template/core/engine/react/source-edit-endpoint.d.mts +10 -0
  32. package/template/core/engine/react/source-edit-endpoint.mjs +75 -0
  33. package/template/core/engine/react/sources/mdx-resolver.mjs +13 -15
  34. package/template/core/engine/react/style-discovery.mjs +23 -8
  35. package/template/core/engine/runtime/config.d.mts +8 -0
  36. package/template/core/engine/runtime/config.mjs +57 -60
  37. package/template/core/engine/runtime/file-utils.mjs +9 -1
  38. package/template/core/engine/runtime/file-walk.mjs +22 -0
  39. package/template/core/engine/runtime/inspection.mjs +1 -20
  40. package/template/core/engine/runtime/page-geometry.mjs +131 -0
  41. package/template/core/engine/runtime/path-utils.mjs +20 -0
  42. package/template/core/engine/runtime/source-text-tools.d.mts +102 -0
  43. package/template/core/engine/runtime/source-text-tools.mjs +551 -16
  44. package/template/core/engine/runtime/source-workspace.mjs +16 -34
  45. package/template/core/engine/runtime/validation.mjs +19 -10
  46. package/template/core/openpress.config.mjs +3 -7
  47. package/template/core/package.json +3 -5
  48. package/template/core/src/main.tsx +2 -2
  49. package/template/core/src/openpress/app/OpenPressApp.tsx +296 -0
  50. package/template/core/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
  51. package/template/core/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  52. package/template/core/src/openpress/app/index.ts +2 -0
  53. package/template/core/src/openpress/core/Frame.tsx +26 -15
  54. package/template/core/src/openpress/core/FrameContext.tsx +10 -3
  55. package/template/core/src/openpress/core/MdxArea.tsx +11 -12
  56. package/template/core/src/openpress/core/Press.tsx +25 -4
  57. package/template/core/src/openpress/core/Workspace.tsx +36 -0
  58. package/template/core/src/openpress/core/cn.ts +4 -0
  59. package/template/core/src/openpress/core/index.tsx +11 -3
  60. package/template/core/src/openpress/core/primitives.tsx +74 -6
  61. package/template/core/src/openpress/core/types.ts +94 -41
  62. package/template/core/src/openpress/core/useSource.ts +1 -1
  63. package/template/core/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  64. package/template/core/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  65. package/template/core/src/openpress/{types.ts → document-model/documentTypes.ts} +51 -0
  66. package/template/core/src/openpress/document-model/index.ts +7 -0
  67. package/template/core/src/openpress/document-model/objectEntityModel.ts +55 -0
  68. package/template/core/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  69. package/template/core/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  70. package/template/core/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  71. package/template/core/src/openpress/manuscript/index.tsx +49 -7
  72. package/template/core/src/openpress/mdx/index.ts +15 -7
  73. package/template/core/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  74. package/template/core/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  75. package/template/core/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  76. package/template/core/src/openpress/reader/index.ts +11 -0
  77. package/template/core/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  78. package/template/core/src/openpress/reader/readerTypes.ts +4 -0
  79. package/template/core/src/openpress/reader/usePageViewportScale.ts +119 -0
  80. package/template/core/src/openpress/reader/usePanelState.ts +56 -0
  81. package/template/core/src/openpress/reader/useReaderHashSync.ts +61 -0
  82. package/template/core/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  83. package/template/core/src/openpress/reader/useReaderRuntime.ts +146 -0
  84. package/template/core/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  85. package/template/core/src/openpress/shared/Panel.tsx +77 -0
  86. package/template/core/src/openpress/shared/index.ts +4 -0
  87. package/template/core/src/openpress/shared/numberUtils.ts +3 -0
  88. package/template/core/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  89. package/template/core/src/openpress/workbench/Workbench.tsx +506 -0
  90. package/template/core/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  91. package/template/core/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  92. package/template/core/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  93. package/template/core/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  94. package/template/core/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  95. package/template/core/src/openpress/workbench/actions/index.ts +6 -0
  96. package/template/core/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  97. package/template/core/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  98. package/template/core/src/openpress/workbench/dialog/index.ts +1 -0
  99. package/template/core/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  100. package/template/core/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  101. package/template/core/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  102. package/template/core/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  103. package/template/core/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  104. package/template/core/src/openpress/workbench/document/index.ts +10 -0
  105. package/template/core/src/openpress/workbench/index.ts +2 -0
  106. package/template/core/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  107. package/template/core/src/openpress/workbench/inspector/index.ts +5 -0
  108. package/template/core/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  109. package/template/core/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  110. package/template/core/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  111. package/template/core/src/openpress/workbench/inspector/useInspectorComments.ts +254 -0
  112. package/template/core/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  113. package/template/core/src/openpress/workbench/mentions/index.ts +2 -0
  114. package/template/core/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  115. package/template/core/src/openpress/workbench/panels/Panel.tsx +1 -0
  116. package/template/core/src/openpress/workbench/panels/PendingCommentsPanel.tsx +80 -0
  117. package/template/core/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  118. package/template/core/src/openpress/workbench/panels/index.ts +3 -0
  119. package/template/core/src/openpress/workbench/project/ProjectEntryPanel.tsx +525 -0
  120. package/template/core/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  121. package/template/core/src/openpress/workbench/project/index.ts +2 -0
  122. package/template/core/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  123. package/template/core/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  124. package/template/core/src/openpress/workbench/shell/index.ts +1 -0
  125. package/template/core/src/openpress/workbench/workbenchFormatters.ts +120 -0
  126. package/template/core/src/openpress/workbench/workbenchTypes.ts +35 -0
  127. package/template/core/src/styles/openpress/print-route.css +0 -2
  128. package/template/core/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  129. package/template/core/src/styles/openpress/public-viewer.css +25 -320
  130. package/template/core/src/styles/openpress/reader-runtime.css +252 -55
  131. package/template/core/src/styles/openpress/responsive.css +145 -270
  132. package/template/core/src/styles/openpress/workbench-panels.css +327 -178
  133. package/template/core/src/styles/openpress/workbench.css +986 -451
  134. package/template/core/src/styles/openpress/workspace-gallery.css +300 -0
  135. package/template/core/src/styles/openpress.css +2 -1
  136. package/template/core/tsconfig.json +1 -1
  137. package/template/core/vite.config.ts +50 -0
  138. package/template/core/engine/commands/init.mjs +0 -24
  139. package/template/core/engine/init.mjs +0 -90
  140. package/template/core/src/openpress/App.tsx +0 -127
  141. package/template/core/src/openpress/inspector.ts +0 -282
  142. package/template/core/src/openpress/projectWorkspace.tsx +0 -919
  143. package/template/core/src/openpress/readerRuntime.ts +0 -230
  144. package/template/core/src/openpress/workbench.tsx +0 -1265
  145. package/template/core/src/openpress/workbenchTypes.ts +0 -4
  146. package/template/packs/academic-paper/document/chapters/01-introduction/content/01-introduction.mdx +0 -35
  147. package/template/packs/academic-paper/document/chapters/02-methods/content/01-methods.mdx +0 -50
  148. package/template/packs/academic-paper/document/chapters/03-results-and-discussion/content/01-results.mdx +0 -47
  149. package/template/packs/academic-paper/document/chapters/04-acknowledgment/content/01-acknowledgment.mdx +0 -26
  150. package/template/packs/academic-paper/document/chapters/05-references/content/01-references.mdx +0 -32
  151. package/template/packs/academic-paper/document/components/ChapterOpenerVisual/index.tsx +0 -76
  152. package/template/packs/academic-paper/document/components/Page.tsx +0 -60
  153. package/template/packs/academic-paper/document/components/TokenSwatchGrid/index.tsx +0 -46
  154. package/template/packs/academic-paper/document/components/TokenSwatchGrid/style.css +0 -63
  155. package/template/packs/academic-paper/document/components/TypeSpecimen/index.tsx +0 -38
  156. package/template/packs/academic-paper/document/components/TypeSpecimen/style.css +0 -111
  157. package/template/packs/academic-paper/document/design.md +0 -279
  158. package/template/packs/academic-paper/document/index.tsx +0 -123
  159. package/template/packs/academic-paper/document/media/README.md +0 -13
  160. package/template/packs/academic-paper/document/media/figure-placeholder.svg +0 -9
  161. package/template/packs/academic-paper/document/openpress.config.mjs +0 -26
  162. package/template/packs/academic-paper/document/theme/README.md +0 -11
  163. package/template/packs/academic-paper/document/theme/base/page-contract.css +0 -522
  164. package/template/packs/academic-paper/document/theme/base/print.css +0 -93
  165. package/template/packs/academic-paper/document/theme/base/typography.css +0 -333
  166. package/template/packs/academic-paper/document/theme/fonts.css +0 -3
  167. package/template/packs/academic-paper/document/theme/page-surfaces/back-cover.css +0 -43
  168. package/template/packs/academic-paper/document/theme/page-surfaces/chapter-opener.css +0 -205
  169. package/template/packs/academic-paper/document/theme/page-surfaces/cover.css +0 -294
  170. package/template/packs/academic-paper/document/theme/page-surfaces/toc.css +0 -149
  171. package/template/packs/academic-paper/document/theme/patterns/_chart-frame.css +0 -49
  172. package/template/packs/academic-paper/document/theme/patterns/figure-grid.css +0 -68
  173. package/template/packs/academic-paper/document/theme/patterns/table-utilities.css +0 -66
  174. package/template/packs/academic-paper/document/theme/shell/reader-controls.css +0 -761
  175. package/template/packs/academic-paper/document/theme/tokens.css +0 -80
  176. package/template/packs/academic-paper/openpress.config.mjs +0 -5
  177. package/template/packs/claude-document/document/chapters/01-document-shape/content/01-document-shape.mdx +0 -51
  178. package/template/packs/claude-document/document/chapters/02-review-loop/content/01-review-loop.mdx +0 -31
  179. package/template/packs/claude-document/document/components/ChapterOpenerVisual.tsx +0 -96
  180. package/template/packs/claude-document/document/components/Page.tsx +0 -37
  181. package/template/packs/claude-document/document/design.md +0 -142
  182. package/template/packs/claude-document/document/index.tsx +0 -94
  183. package/template/packs/claude-document/document/media/README.md +0 -13
  184. package/template/packs/claude-document/document/openpress.config.mjs +0 -26
  185. package/template/packs/claude-document/document/theme/README.md +0 -15
  186. package/template/packs/claude-document/document/theme/base/page-contract.css +0 -525
  187. package/template/packs/claude-document/document/theme/base/print.css +0 -93
  188. package/template/packs/claude-document/document/theme/base/typography.css +0 -612
  189. package/template/packs/claude-document/document/theme/fonts.css +0 -4
  190. package/template/packs/claude-document/document/theme/page-surfaces/back-cover.css +0 -72
  191. package/template/packs/claude-document/document/theme/page-surfaces/chapter-opener.css +0 -236
  192. package/template/packs/claude-document/document/theme/page-surfaces/cover.css +0 -309
  193. package/template/packs/claude-document/document/theme/page-surfaces/toc.css +0 -225
  194. package/template/packs/claude-document/document/theme/patterns/_chart-frame.css +0 -53
  195. package/template/packs/claude-document/document/theme/patterns/figure-grid.css +0 -68
  196. package/template/packs/claude-document/document/theme/patterns/table-utilities.css +0 -66
  197. package/template/packs/claude-document/document/theme/shell/reader-controls.css +0 -789
  198. package/template/packs/claude-document/document/theme/tokens.css +0 -89
  199. package/template/packs/claude-document/openpress.config.mjs +0 -5
  200. package/template/packs/editorial-monograph/document/chapters/01-product-and-use-cases/content/01-product-and-use-cases.mdx +0 -31
  201. package/template/packs/editorial-monograph/document/chapters/02-workflow/content/01-workflow.mdx +0 -89
  202. package/template/packs/editorial-monograph/document/chapters/03-agent-skills-contributors/content/01-agent-skills-contributors.mdx +0 -51
  203. package/template/packs/editorial-monograph/document/chapters/04-validation-deploy/content/01-validation-deploy.mdx +0 -39
  204. package/template/packs/editorial-monograph/document/components/ChapterOpenerVisual/index.tsx +0 -76
  205. package/template/packs/editorial-monograph/document/components/Page.tsx +0 -37
  206. package/template/packs/editorial-monograph/document/components/TokenSwatchGrid/index.tsx +0 -46
  207. package/template/packs/editorial-monograph/document/components/TokenSwatchGrid/style.css +0 -63
  208. package/template/packs/editorial-monograph/document/components/TypeSpecimen/index.tsx +0 -38
  209. package/template/packs/editorial-monograph/document/components/TypeSpecimen/style.css +0 -111
  210. package/template/packs/editorial-monograph/document/design.md +0 -279
  211. package/template/packs/editorial-monograph/document/index.tsx +0 -97
  212. package/template/packs/editorial-monograph/document/media/README.md +0 -13
  213. package/template/packs/editorial-monograph/document/openpress.config.mjs +0 -26
  214. package/template/packs/editorial-monograph/document/theme/README.md +0 -11
  215. package/template/packs/editorial-monograph/document/theme/base/page-contract.css +0 -505
  216. package/template/packs/editorial-monograph/document/theme/base/print.css +0 -93
  217. package/template/packs/editorial-monograph/document/theme/base/typography.css +0 -336
  218. package/template/packs/editorial-monograph/document/theme/fonts.css +0 -3
  219. package/template/packs/editorial-monograph/document/theme/page-surfaces/back-cover.css +0 -43
  220. package/template/packs/editorial-monograph/document/theme/page-surfaces/chapter-opener.css +0 -205
  221. package/template/packs/editorial-monograph/document/theme/page-surfaces/cover.css +0 -147
  222. package/template/packs/editorial-monograph/document/theme/page-surfaces/toc.css +0 -149
  223. package/template/packs/editorial-monograph/document/theme/patterns/_chart-frame.css +0 -49
  224. package/template/packs/editorial-monograph/document/theme/patterns/figure-grid.css +0 -68
  225. package/template/packs/editorial-monograph/document/theme/patterns/table-utilities.css +0 -66
  226. package/template/packs/editorial-monograph/document/theme/shell/reader-controls.css +0 -761
  227. package/template/packs/editorial-monograph/document/theme/tokens.css +0 -80
  228. package/template/packs/editorial-monograph/openpress.config.mjs +0 -5
  229. /package/template/core/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  230. /package/template/core/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  231. /package/template/core/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  232. /package/template/core/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  233. /package/template/core/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  234. /package/template/core/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
+ }