@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.
Files changed (144) hide show
  1. package/README.md +6 -3
  2. package/engine/cli.mjs +8 -8
  3. package/engine/commands/_shared.mjs +37 -15
  4. package/engine/commands/dev.mjs +2 -2
  5. package/engine/commands/image.mjs +29 -0
  6. package/engine/commands/skills-sync.mjs +71 -0
  7. package/engine/commands/typecheck.mjs +63 -1
  8. package/engine/commands/upgrade.mjs +3 -3
  9. package/engine/document-export.mjs +1 -1
  10. package/engine/output/chrome-pdf.mjs +110 -3
  11. package/engine/output/static-server.mjs +87 -9
  12. package/engine/react/comment-endpoint.mjs +13 -39
  13. package/engine/react/comment-marker.mjs +43 -19
  14. package/engine/react/document-entry.mjs +46 -28
  15. package/engine/react/document-export.mjs +328 -164
  16. package/engine/react/http-json.mjs +24 -0
  17. package/engine/react/mdx-compile.mjs +126 -3
  18. package/engine/react/measurement-css.mjs +114 -1
  19. package/engine/react/object-entities.mjs +204 -0
  20. package/engine/react/pagination/allocator.mjs +48 -3
  21. package/engine/react/pagination.mjs +1 -1
  22. package/engine/react/pipeline/allocate.mjs +41 -72
  23. package/engine/react/pipeline/frame-measurement.mjs +6 -0
  24. package/engine/react/press-tree-inspection.mjs +172 -0
  25. package/engine/react/project-asset-endpoint.mjs +6 -24
  26. package/engine/react/source-edit-endpoint.d.mts +10 -0
  27. package/engine/react/source-edit-endpoint.mjs +75 -0
  28. package/engine/react/sources/mdx-resolver.mjs +13 -15
  29. package/engine/react/style-discovery.mjs +23 -8
  30. package/engine/runtime/config.d.mts +8 -0
  31. package/engine/runtime/config.mjs +57 -60
  32. package/engine/runtime/file-utils.mjs +9 -1
  33. package/engine/runtime/file-walk.mjs +22 -0
  34. package/engine/runtime/inspection.mjs +1 -20
  35. package/engine/runtime/page-geometry.mjs +131 -0
  36. package/engine/runtime/path-utils.mjs +20 -0
  37. package/engine/runtime/source-text-tools.d.mts +102 -0
  38. package/engine/runtime/source-text-tools.mjs +551 -16
  39. package/engine/runtime/source-workspace.mjs +16 -34
  40. package/engine/runtime/validation.mjs +19 -10
  41. package/package.json +3 -5
  42. package/src/openpress/app/OpenPressApp.tsx +296 -0
  43. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +20 -9
  44. package/src/openpress/app/WorkspaceGalleryPage.tsx +219 -0
  45. package/src/openpress/app/index.ts +2 -0
  46. package/src/openpress/core/Frame.tsx +26 -15
  47. package/src/openpress/core/FrameContext.tsx +10 -3
  48. package/src/openpress/core/MdxArea.tsx +11 -12
  49. package/src/openpress/core/Press.tsx +25 -4
  50. package/src/openpress/core/Workspace.tsx +36 -0
  51. package/src/openpress/core/cn.ts +4 -0
  52. package/src/openpress/core/index.tsx +11 -3
  53. package/src/openpress/core/primitives.tsx +74 -6
  54. package/src/openpress/core/types.ts +94 -41
  55. package/src/openpress/core/useSource.ts +1 -1
  56. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  57. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  58. package/src/openpress/{types.ts → document-model/documentTypes.ts} +51 -0
  59. package/src/openpress/document-model/index.ts +7 -0
  60. package/src/openpress/document-model/objectEntityModel.ts +55 -0
  61. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  62. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  63. package/src/openpress/document-model/workspaceManifestModel.ts +57 -0
  64. package/src/openpress/manuscript/index.tsx +49 -7
  65. package/src/openpress/mdx/index.ts +15 -7
  66. package/src/openpress/reader/PageThumbnailsPanel.tsx +168 -0
  67. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  68. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  69. package/src/openpress/reader/index.ts +11 -0
  70. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  71. package/src/openpress/reader/readerTypes.ts +4 -0
  72. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  73. package/src/openpress/reader/usePanelState.ts +56 -0
  74. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  75. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  76. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  77. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  78. package/src/openpress/shared/Panel.tsx +77 -0
  79. package/src/openpress/shared/index.ts +4 -0
  80. package/src/openpress/shared/numberUtils.ts +3 -0
  81. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  82. package/src/openpress/workbench/Workbench.tsx +506 -0
  83. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  84. package/src/openpress/workbench/actions/ExportImageControl.tsx +96 -0
  85. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  86. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  87. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  88. package/src/openpress/workbench/actions/index.ts +6 -0
  89. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  90. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  91. package/src/openpress/workbench/dialog/index.ts +1 -0
  92. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  93. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  94. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  95. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  96. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  97. package/src/openpress/workbench/document/index.ts +10 -0
  98. package/src/openpress/workbench/index.ts +2 -0
  99. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  100. package/src/openpress/workbench/inspector/index.ts +5 -0
  101. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  102. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  103. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  104. package/src/openpress/workbench/inspector/useInspectorComments.ts +254 -0
  105. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  106. package/src/openpress/workbench/mentions/index.ts +2 -0
  107. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  108. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  109. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +80 -0
  110. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  111. package/src/openpress/workbench/panels/index.ts +3 -0
  112. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +525 -0
  113. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  114. package/src/openpress/workbench/project/index.ts +2 -0
  115. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  116. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  117. package/src/openpress/workbench/shell/index.ts +1 -0
  118. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  119. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  120. package/src/styles/openpress/print-route.css +0 -2
  121. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  122. package/src/styles/openpress/public-viewer.css +25 -320
  123. package/src/styles/openpress/reader-runtime.css +252 -55
  124. package/src/styles/openpress/responsive.css +145 -270
  125. package/src/styles/openpress/workbench-panels.css +327 -178
  126. package/src/styles/openpress/workbench.css +986 -451
  127. package/src/styles/openpress/workspace-gallery.css +300 -0
  128. package/src/styles/openpress.css +2 -1
  129. package/tsconfig.json +1 -1
  130. package/vite.config.ts +50 -0
  131. package/engine/commands/init.mjs +0 -24
  132. package/engine/init.mjs +0 -90
  133. package/src/openpress/App.tsx +0 -127
  134. package/src/openpress/inspector.ts +0 -282
  135. package/src/openpress/projectWorkspace.tsx +0 -919
  136. package/src/openpress/readerRuntime.ts +0 -230
  137. package/src/openpress/workbench.tsx +0 -1265
  138. package/src/openpress/workbenchTypes.ts +0 -4
  139. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  140. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  141. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  142. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  143. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  144. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -0,0 +1,506 @@
1
+ import {
2
+ useMemo,
3
+ useRef,
4
+ useState,
5
+ type CSSProperties,
6
+ } from "react";
7
+ import { ExternalLink, Home, MousePointer2, Ruler } from "lucide-react";
8
+ import {
9
+ getProjectIdentity,
10
+ resolveAnchorPageIndex,
11
+ type DeploymentInfo,
12
+ type HtmlPageBlock,
13
+ type ReaderDocument,
14
+ } from "../document-model";
15
+ import { InlineInspectorLayer, useInspector, useInspectorComments } from "./inspector";
16
+ import { ProjectEntryPanel } from "./project";
17
+ import {
18
+ Bookmarks,
19
+ CurrentPagePanel,
20
+ PageThumbnails,
21
+ PUBLIC_DRAWER_BREAKPOINT,
22
+ PublicPage,
23
+ useReaderRuntime,
24
+ usePageViewportScale,
25
+ useViewMode,
26
+ type PageLayoutMode,
27
+ } from "../reader";
28
+ import {
29
+ ReaderStage,
30
+ InlineSourceEditorLayer,
31
+ useDocumentWorkbenchModel,
32
+ useInlineDocumentEditor,
33
+ type InlineDocumentEditStatus,
34
+ type InlineDocumentSourceTarget,
35
+ } from "./document";
36
+ import {
37
+ DeploymentControl,
38
+ ExportImageControl,
39
+ PageZoomControl,
40
+ SearchControl,
41
+ useDeploymentWorkbench,
42
+ } from "./actions";
43
+ import { PendingCommentsPanel, WorkbenchControlPanel, type WorkbenchPanel } from "./panels";
44
+ import { WorkbenchShell } from "./shell";
45
+ import {
46
+ formatPageGeometrySpec,
47
+ formatInspectorSelection,
48
+ } from "./workbenchFormatters";
49
+
50
+ export function HtmlWorkbench({
51
+ document,
52
+ pages,
53
+ style,
54
+ devMode,
55
+ deploymentInfo,
56
+ onDocumentRefresh,
57
+ onBackToWorkspace,
58
+ extraControlPanels,
59
+ }: {
60
+ document: ReaderDocument;
61
+ pages: Array<HtmlPageBlock>;
62
+ style: CSSProperties;
63
+ devMode: boolean;
64
+ deploymentInfo: DeploymentInfo;
65
+ onDocumentRefresh?: () => void | Promise<void>;
66
+ onBackToWorkspace?: () => void;
67
+ // Append extra panels into the right-side control panel. Built-in panels
68
+ // (pending comments + project entry) render first; extra panels render
69
+ // after them in the supplied order.
70
+ extraControlPanels?: WorkbenchPanel[];
71
+ }) {
72
+ const sourceContainerRef = useRef<HTMLDivElement | null>(null);
73
+ const displayPages = pages;
74
+ const { viewMode } = useViewMode();
75
+ const {
76
+ mediaAssets,
77
+ anchorPageMap,
78
+ projectComponentUsages,
79
+ bookmarks,
80
+ sourceBlockMap,
81
+ sourceBlocksByPath,
82
+ projectMentionItems,
83
+ } = useDocumentWorkbenchModel(document, displayPages);
84
+ const inspector = useInspector(document, { enabled: devMode });
85
+ const reader = useReaderRuntime({
86
+ pageCount: Math.max(displayPages.length, 1),
87
+ leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
88
+ rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
89
+ });
90
+ const [pageLayoutMode, setPageLayoutMode] = useState<PageLayoutMode>("single");
91
+ const pageViewport = usePageViewportScale({
92
+ stageRef: reader.stageRef,
93
+ pageContainerRef: sourceContainerRef,
94
+ pageCount: displayPages.length,
95
+ layoutMode: pageLayoutMode,
96
+ });
97
+ const deployment = useDeploymentWorkbench({ deploymentInfo });
98
+ const [inlineEditStatus, setInlineEditStatus] = useState<InlineDocumentEditStatus>({ state: "idle" });
99
+ const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
100
+
101
+ const projectIdentity = getProjectIdentity(document.meta);
102
+ const pageGeometry = formatPageGeometrySpec(document.theme);
103
+ const inspectorSelectionLabel = formatInspectorSelection(
104
+ inspector.selectedBlock,
105
+ inspector.selectedObjectEntity,
106
+ );
107
+ const inspectorToolbarExpanded = inspector.inspectorMode;
108
+ const editStatusMessage = formatInlineEditStatus(inlineEditStatus);
109
+
110
+ // Inline source editing and inspector commenting are mutually exclusive
111
+ // interaction modes on the same blocks. While inspector mode is on, the
112
+ // user is selecting blocks to comment on — keeping contenteditable + the
113
+ // text cursor active would (a) show the I-beam instead of the inspector
114
+ // crosshair, (b) allow accidental text selection that paints the whole
115
+ // page (notably covers) with the browser ::selection color.
116
+ const inlineEditEnabled = devMode && !inspector.inspectorMode;
117
+ useInlineDocumentEditor({
118
+ enabled: inlineEditEnabled,
119
+ sourceContainerRef,
120
+ sourceBlockMap,
121
+ onStatusChange: setInlineEditStatus,
122
+ onOpenSourceBlock: setSourceEditorTarget,
123
+ onDocumentEdited: onDocumentRefresh,
124
+ });
125
+
126
+ const selectWorkspacePage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
127
+ reader.setPage(pageIndex, options);
128
+ if (
129
+ typeof window !== "undefined"
130
+ && window.innerWidth < PUBLIC_DRAWER_BREAKPOINT
131
+ && reader.rightPanelOpen
132
+ ) {
133
+ reader.toggleRightPanel();
134
+ }
135
+ };
136
+
137
+ const selectWorkspaceAnchor = (anchorId: string, pageIndex?: number) => {
138
+ const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex);
139
+ if (targetPageIndex === null) return false;
140
+ selectWorkspacePage(targetPageIndex, { behavior: "smooth" });
141
+ return true;
142
+ };
143
+
144
+ const comments = useInspectorComments({
145
+ devMode,
146
+ inspector,
147
+ sourceBlockMap,
148
+ sourceBlocksByPath,
149
+ sourceContainerRef,
150
+ onSelectWorkspacePage: selectWorkspacePage,
151
+ });
152
+
153
+ // Stabilize the controller objects so memoized InlineInspectorLayer can skip
154
+ // re-rendering when nothing observable changed.
155
+ const inspectorLayerComments = useMemo(() => ({
156
+ saved: comments.inlineSavedComments,
157
+ active: comments.activeInlineSavedComment ?? null,
158
+ status: comments.inspectorCommentStatus,
159
+ statusMessage: comments.inspectorCommentStatusMessage,
160
+ totalCount: comments.pendingComments.length,
161
+ onOpenSaved: comments.handleOpenInlineSavedComment,
162
+ onRemoveSaved: comments.handleRemoveInlineSavedComment,
163
+ }), [
164
+ comments.activeInlineSavedComment,
165
+ comments.handleOpenInlineSavedComment,
166
+ comments.handleRemoveInlineSavedComment,
167
+ comments.inlineSavedComments,
168
+ comments.inspectorCommentStatus,
169
+ comments.inspectorCommentStatusMessage,
170
+ comments.pendingComments.length,
171
+ ]);
172
+ const inspectorLayerComposer = useMemo(() => ({
173
+ text: comments.inspectorCommentText,
174
+ submitDisabled: comments.inspectorCommentDisabled,
175
+ mentionItems: projectMentionItems,
176
+ onTextChange: comments.setInspectorCommentText,
177
+ onSubmit: comments.handleSubmitInspectorComment,
178
+ }), [
179
+ comments.handleSubmitInspectorComment,
180
+ comments.inspectorCommentDisabled,
181
+ comments.inspectorCommentText,
182
+ comments.setInspectorCommentText,
183
+ projectMentionItems,
184
+ ]);
185
+
186
+ const currentSourcePath = displayPages[reader.currentPageIndex]?.source;
187
+ // Stabilize the panel registry across keystrokes in the inspector
188
+ // composer. Without `useMemo` the registry array (and the JSX closures
189
+ // inside) would be recreated on every Workbench render, so typing a
190
+ // single character would force WorkbenchControlPanel + every panel to
191
+ // diff fresh React elements.
192
+ const builtInControlPanels = useMemo<WorkbenchPanel[]>(() => [
193
+ {
194
+ id: "pending-comments",
195
+ render: () => (
196
+ <PendingCommentsPanel
197
+ comments={comments.pendingComments}
198
+ status={comments.commentsStatus}
199
+ error={comments.commentsError}
200
+ onClear={comments.clearPendingComment}
201
+ onSelect={comments.handleSelectPendingComment}
202
+ />
203
+ ),
204
+ },
205
+ {
206
+ id: "project-entry",
207
+ render: () => (
208
+ <ProjectEntryPanel
209
+ mediaAssets={mediaAssets}
210
+ componentUsages={projectComponentUsages}
211
+ mentionItems={projectMentionItems}
212
+ currentSource={currentSourcePath}
213
+ onCommentSubmitted={comments.refreshPendingComments}
214
+ />
215
+ ),
216
+ },
217
+ ], [
218
+ comments.clearPendingComment,
219
+ comments.commentsError,
220
+ comments.commentsStatus,
221
+ comments.handleSelectPendingComment,
222
+ comments.pendingComments,
223
+ comments.refreshPendingComments,
224
+ currentSourcePath,
225
+ mediaAssets,
226
+ projectComponentUsages,
227
+ projectMentionItems,
228
+ ]);
229
+ const controlPanels = useMemo(
230
+ () => (extraControlPanels ? [...builtInControlPanels, ...extraControlPanels] : builtInControlPanels),
231
+ [builtInControlPanels, extraControlPanels],
232
+ );
233
+
234
+ // Memoize so composer keystrokes (which only flip `comments.inspectorCommentText`)
235
+ // don't rebuild the toolbar JSX. The toolbar depends on deploy/page/zoom
236
+ // state and inspector mode, but never on the composer draft text.
237
+ const toolbarActions = useMemo(() => (
238
+ <>
239
+ {onBackToWorkspace ? (
240
+ <div className="openpress-workbench-toolbar__group" aria-label="工作台導覽">
241
+ <button
242
+ type="button"
243
+ className="openpress-workbench-toolbar-action openpress-workbench-toolbar-action--back"
244
+ data-openpress-back-to-workspace
245
+ onClick={onBackToWorkspace}
246
+ title="回到工作台"
247
+ aria-label="回到工作台"
248
+ >
249
+ <Home aria-hidden="true" />
250
+ <span className="openpress-workbench-toolbar-action__label">工作台</span>
251
+ </button>
252
+ </div>
253
+ ) : null}
254
+ <div className="openpress-workbench-toolbar__group" aria-label="輸出">
255
+ <button
256
+ type="button"
257
+ className="openpress-workbench-toolbar-action"
258
+ data-openpress-public-export
259
+ data-openpress-toolbar-expanded={deployment.pdfToolbarExpanded ? "true" : "false"}
260
+ data-openpress-toolbar-active={deployment.pdfToolbarExpanded ? "true" : "false"}
261
+ disabled={deployment.pdfButtonDisabled}
262
+ onClick={deployment.handleOpenWorkbenchPdf}
263
+ title={deployment.pdfButtonText}
264
+ aria-label={deployment.pdfButtonText}
265
+ >
266
+ <ExternalLink aria-hidden="true" />
267
+ <span className="openpress-workbench-toolbar-action__label">{deployment.pdfButtonText}</span>
268
+ {deployment.pdfStatusMessage ? (
269
+ <span
270
+ className="openpress-dev-pdf-status"
271
+ data-openpress-pdf-status={deployment.pdfActionStatus}
272
+ role="status"
273
+ aria-live="polite"
274
+ >
275
+ <span className="openpress-dev-pdf-status__spinner" aria-hidden="true" />
276
+ <span>{deployment.pdfStatusMessage}</span>
277
+ </span>
278
+ ) : null}
279
+ </button>
280
+ <ExportImageControl
281
+ currentPageIndex={reader.currentPageIndex}
282
+ currentPageLabel={reader.currentPageLabel}
283
+ pressTitle={projectIdentity.name}
284
+ />
285
+ </div>
286
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
287
+ <button
288
+ type="button"
289
+ className="openpress-workbench-page-geometry"
290
+ data-openpress-page-geometry
291
+ title={pageGeometry.title}
292
+ aria-label={`頁面規格 ${pageGeometry.title}`}
293
+ >
294
+ <Ruler aria-hidden="true" />
295
+ <span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
296
+ <span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
297
+ </button>
298
+ <PageZoomControl
299
+ scaleMode={pageViewport.scaleMode}
300
+ scaleLabel={pageViewport.scaleLabel}
301
+ pageLayoutMode={pageLayoutMode}
302
+ onScaleModeChange={pageViewport.setScaleMode}
303
+ onPageLayoutModeChange={setPageLayoutMode}
304
+ />
305
+ </div>
306
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
307
+ {devMode ? (
308
+ <SearchControl
309
+ sourceBlocksByPath={sourceBlocksByPath}
310
+ onSelectPage={selectWorkspacePage}
311
+ />
312
+ ) : null}
313
+ {devMode && editStatusMessage ? (
314
+ <span
315
+ className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
316
+ data-openpress-edit-status={inlineEditStatus.state}
317
+ role="status"
318
+ aria-live="polite"
319
+ >
320
+ {inlineEditStatus.state === "saving" ? <span className="openpress-dev-edit-status__spinner" aria-hidden="true" /> : null}
321
+ <span>{editStatusMessage}</span>
322
+ </span>
323
+ ) : null}
324
+ {devMode ? (
325
+ <button
326
+ type="button"
327
+ className="openpress-workbench-toolbar-action"
328
+ data-openpress-inspector-toggle
329
+ data-openpress-inspector-active={inspector.inspectorMode ? "true" : "false"}
330
+ data-openpress-toolbar-expanded={inspectorToolbarExpanded ? "true" : "false"}
331
+ data-openpress-toolbar-active={inspectorToolbarExpanded ? "true" : "false"}
332
+ onClick={() => inspector.setInspectorMode(!inspector.inspectorMode)}
333
+ aria-pressed={inspector.inspectorMode}
334
+ title={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
335
+ aria-label={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
336
+ >
337
+ <MousePointer2 aria-hidden="true" />
338
+ <span className="openpress-workbench-toolbar-action__label">{inspector.inspectorMode ? "註解中" : "註解"}</span>
339
+ <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
340
+ </button>
341
+ ) : null}
342
+ {devMode && inspector.inspectorMode ? (
343
+ <span
344
+ className="openpress-dev-inspector-status"
345
+ role="status"
346
+ aria-live="polite"
347
+ data-openpress-inspector-comment-status={comments.inspectorCommentStatus}
348
+ >
349
+ {comments.inspectorCommentStatusMessage}
350
+ </span>
351
+ ) : null}
352
+ {deployment.localDeployEnabled ? (
353
+ <DeploymentControl
354
+ info={deployment.currentDeploymentInfo}
355
+ status={deployment.status}
356
+ onDeploy={deployment.handleDeploy}
357
+ />
358
+ ) : null}
359
+ </div>
360
+ </>
361
+ ), [
362
+ comments.inspectorCommentStatus,
363
+ comments.inspectorCommentStatusMessage,
364
+ deployment.currentDeploymentInfo,
365
+ deployment.handleDeploy,
366
+ deployment.handleOpenWorkbenchPdf,
367
+ deployment.localDeployEnabled,
368
+ deployment.pdfActionStatus,
369
+ deployment.pdfButtonDisabled,
370
+ deployment.pdfButtonText,
371
+ deployment.pdfStatusMessage,
372
+ deployment.pdfToolbarExpanded,
373
+ deployment.status,
374
+ devMode,
375
+ editStatusMessage,
376
+ inlineEditStatus.state,
377
+ inspector.inspectorMode,
378
+ inspector.setInspectorMode,
379
+ inspectorSelectionLabel,
380
+ inspectorToolbarExpanded,
381
+ pageGeometry.dimensions,
382
+ pageGeometry.label,
383
+ pageGeometry.title,
384
+ pageLayoutMode,
385
+ pageViewport.scaleLabel,
386
+ pageViewport.scaleMode,
387
+ pageViewport.setScaleMode,
388
+ selectWorkspacePage,
389
+ sourceBlocksByPath,
390
+ onBackToWorkspace,
391
+ reader.currentPageIndex,
392
+ reader.currentPageLabel,
393
+ projectIdentity.name,
394
+ ]);
395
+
396
+ return (
397
+ <WorkbenchShell
398
+ style={style}
399
+ devMode={devMode}
400
+ viewMode={viewMode}
401
+ inspectorMode={inspector.inspectorMode}
402
+ editMode={inlineEditEnabled}
403
+ leftPanelOpen={reader.leftPanelOpen}
404
+ rightPanelOpen={reader.rightPanelOpen}
405
+ onToggleLeftPanel={reader.toggleLeftPanel}
406
+ onToggleRightPanel={reader.toggleRightPanel}
407
+ >
408
+ <WorkbenchShell.Toolbar>
409
+ {toolbarActions}
410
+ </WorkbenchShell.Toolbar>
411
+
412
+ <WorkbenchShell.LeftPanel>
413
+ <section className="openpress-public-identity" aria-label="文件資訊">
414
+ <strong>
415
+ <span className="openpress-public-title-main">{projectIdentity.name}</span>
416
+ {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
417
+ </strong>
418
+ {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
419
+ </section>
420
+
421
+ {bookmarks.length > 0 ? (
422
+ <section
423
+ id="openpress-bookmarks"
424
+ className="openpress-panel-section openpress-panel-section--bookmarks"
425
+ aria-label="章節書籤"
426
+ >
427
+ <nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
428
+ <div className="reader-bookmarks-rail" aria-hidden="true" />
429
+ <Bookmarks
430
+ items={bookmarks}
431
+ currentPageIndex={reader.currentPageIndex}
432
+ onSelectPage={selectWorkspacePage}
433
+ />
434
+ </nav>
435
+ </section>
436
+ ) : (
437
+ <section
438
+ id="openpress-thumbnails"
439
+ className="openpress-panel-section openpress-panel-section--thumbnails"
440
+ aria-label="頁面縮圖"
441
+ >
442
+ <PageThumbnails
443
+ pages={displayPages}
444
+ currentPageIndex={reader.currentPageIndex}
445
+ onSelectPage={selectWorkspacePage}
446
+ theme={document.theme}
447
+ />
448
+ </section>
449
+ )}
450
+ <CurrentPagePanel
451
+ currentPageLabel={reader.currentPageLabel}
452
+ totalPageLabel={reader.totalPageLabel}
453
+ progressPercent={reader.progressPercent}
454
+ title={displayPages[reader.currentPageIndex]?.title || document.meta.title}
455
+ pageLabelPrefix="頁"
456
+ showHeading={false}
457
+ showTitle={false}
458
+ />
459
+ </WorkbenchShell.LeftPanel>
460
+
461
+ <WorkbenchShell.RightPanel>
462
+ <WorkbenchControlPanel panels={controlPanels} />
463
+ </WorkbenchShell.RightPanel>
464
+
465
+ <WorkbenchShell.MainContent>
466
+ <ReaderStage ref={reader.stageRef}>
467
+ <PublicPage
468
+ pages={displayPages}
469
+ currentPageIndex={reader.currentPageIndex}
470
+ devMode={devMode}
471
+ sourceContainerRef={sourceContainerRef}
472
+ registerPage={reader.registerPage}
473
+ exposeSourceData={devMode}
474
+ inspector={inspector}
475
+ onInternalAnchorNavigate={selectWorkspaceAnchor}
476
+ pageLayoutMode={pageLayoutMode}
477
+ />
478
+ {devMode ? (
479
+ <InlineInspectorLayer
480
+ sourceContainerRef={sourceContainerRef}
481
+ inspector={inspector}
482
+ comments={inspectorLayerComments}
483
+ composer={inspectorLayerComposer}
484
+ geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}:${pageLayoutMode}`}
485
+ />
486
+ ) : null}
487
+ {devMode ? (
488
+ <InlineSourceEditorLayer
489
+ target={sourceEditorTarget}
490
+ onClose={() => setSourceEditorTarget(null)}
491
+ onStatusChange={setInlineEditStatus}
492
+ geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}:${pageLayoutMode}`}
493
+ />
494
+ ) : null}
495
+ </ReaderStage>
496
+ </WorkbenchShell.MainContent>
497
+ </WorkbenchShell>
498
+ );
499
+ }
500
+
501
+ function formatInlineEditStatus(status: InlineDocumentEditStatus) {
502
+ if (status.state === "saving") return "儲存中";
503
+ if (status.state === "saved") return "已儲存";
504
+ if (status.state === "failed") return "儲存失敗";
505
+ return "";
506
+ }
@@ -0,0 +1,157 @@
1
+ import { useId, useState } from "react";
2
+ import { Check, Rocket } from "lucide-react";
3
+ import type { DeploymentInfo } from "../../document-model";
4
+ import { WorkbenchDialog } from "../dialog";
5
+ import type { DeployStatus } from "../workbenchTypes";
6
+ import {
7
+ deployButtonText,
8
+ deploymentStatusKind,
9
+ deploymentStatusSummary,
10
+ deploymentStatusText,
11
+ } from "./deploymentStatusModel";
12
+
13
+ export function DeploymentControl({
14
+ info,
15
+ status,
16
+ onDeploy,
17
+ }: {
18
+ info: DeploymentInfo;
19
+ status: DeployStatus;
20
+ onDeploy: () => void | Promise<void>;
21
+ }) {
22
+ const titleId = useId();
23
+ const [dialogOpen, setDialogOpen] = useState(false);
24
+ const kind = deploymentStatusKind(info, status);
25
+ const buttonText = deployButtonText(info, status);
26
+ const description = deploymentStatusText(info, status);
27
+ const summary = deploymentStatusSummary(info, status);
28
+ const sourceLabel = deploymentSourceLabel(info);
29
+ const busy = status === "deploying";
30
+ const confirmDisabled = busy || status === "unavailable" || info.configured === false;
31
+
32
+ const confirmDeploy = () => {
33
+ if (confirmDisabled) return;
34
+ setDialogOpen(false);
35
+ void onDeploy();
36
+ };
37
+
38
+ const dialog = dialogOpen ? (
39
+ <WorkbenchDialog
40
+ titleId={titleId}
41
+ title="部署資訊"
42
+ eyebrow="Deployment"
43
+ titleMeta={<span className="openpress-deploy-dialog__source">{sourceLabel}</span>}
44
+ className="openpress-deploy-dialog"
45
+ backdropClassName="openpress-deploy-dialog-backdrop"
46
+ closeLabel="關閉部署資訊"
47
+ onClose={() => setDialogOpen(false)}
48
+ footer={(
49
+ <>
50
+ <button type="button" onClick={() => setDialogOpen(false)}>取消</button>
51
+ <button type="button" disabled={confirmDisabled} onClick={confirmDeploy}>
52
+ <Check aria-hidden="true" />
53
+ <span>{busy ? "部署中" : "確認部署"}</span>
54
+ </button>
55
+ </>
56
+ )}
57
+ >
58
+ <dl data-openpress-deploy-align="left-values">
59
+ <DeployStatusRow label="狀態" value={summary} kind={kind} />
60
+ <DeployLinkRow label="公開頁面" url={info.publicUrl} />
61
+ <DeployLinkRow label="PDF" url={info.pdf} />
62
+ </dl>
63
+ {info.configured === false ? (
64
+ <p className="openpress-deploy-dialog__message" role="status">
65
+ {info.setupMessage ?? "部署設定尚未完成。"}
66
+ </p>
67
+ ) : null}
68
+ </WorkbenchDialog>
69
+ ) : null;
70
+
71
+ return (
72
+ <>
73
+ <button
74
+ type="button"
75
+ className="openpress-workbench-toolbar-action"
76
+ data-openpress-deploy
77
+ data-openpress-deploy-status={kind}
78
+ data-openpress-toolbar-expanded="false"
79
+ data-openpress-toolbar-active="false"
80
+ data-deploy-status={status}
81
+ aria-busy={busy ? "true" : "false"}
82
+ aria-label={buttonText}
83
+ title={description}
84
+ onClick={() => setDialogOpen(true)}
85
+ >
86
+ <Rocket aria-hidden="true" />
87
+ </button>
88
+ {busy ? (
89
+ <span
90
+ className="openpress-dev-deploy-status openpress-dev-deploy-status--toolbar"
91
+ data-openpress-deploy-status={kind}
92
+ role="status"
93
+ aria-live="polite"
94
+ >
95
+ <span className="openpress-dev-deploy-status__dot" aria-hidden="true" />
96
+ <span>部署中</span>
97
+ </span>
98
+ ) : null}
99
+ {dialog}
100
+ </>
101
+ );
102
+ }
103
+
104
+ function DeployStatusRow({ label, value, kind }: { label: string; value: string; kind: string }) {
105
+ return (
106
+ <div>
107
+ <dt>{label}</dt>
108
+ <dd>
109
+ <span className="openpress-deploy-dialog__status" data-openpress-deploy-status={kind}>
110
+ {value}
111
+ </span>
112
+ </dd>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function DeployLinkRow({ label, url }: { label: string; url?: string }) {
118
+ return (
119
+ <div>
120
+ <dt>{label}</dt>
121
+ <dd>
122
+ {url ? (
123
+ <a href={url} target="_blank" rel="noreferrer">
124
+ {formatDeployUrl(url)}
125
+ </a>
126
+ ) : (
127
+ "尚未產生"
128
+ )}
129
+ </dd>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function deploymentSourceLabel(info: DeploymentInfo) {
135
+ const adapter = info.adapter?.trim().toLowerCase();
136
+
137
+ if (adapter === "cloudflare-pages" || adapter === "cloudflare") return "Cloudflare Pages";
138
+ if (adapter === "github-pages" || adapter === "github") return "GitHub Pages";
139
+ if (adapter === "zeabur" || adapter === "zebur") return "Zeabur";
140
+
141
+ return info.projectName?.trim() || info.source?.trim() || info.adapter?.trim() || "本機工作區";
142
+ }
143
+
144
+ function formatDeployUrl(value: string) {
145
+ try {
146
+ const url = new URL(value);
147
+ const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
148
+ return shortenDeployUrl(`${url.host}${pathname}`);
149
+ } catch {
150
+ return shortenDeployUrl(value);
151
+ }
152
+ }
153
+
154
+ function shortenDeployUrl(value: string) {
155
+ if (value.length <= 48) return value;
156
+ return `${value.slice(0, 30)}...${value.slice(-14)}`;
157
+ }