@open-press/core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/engine/commands/dev.mjs +2 -2
  2. package/engine/commands/upgrade.mjs +47 -5
  3. package/engine/output/chrome-pdf.mjs +18 -3
  4. package/engine/output/static-server.mjs +39 -0
  5. package/engine/react/comment-endpoint.mjs +13 -39
  6. package/engine/react/comment-marker.mjs +30 -6
  7. package/engine/react/document-entry.mjs +11 -0
  8. package/engine/react/document-export.mjs +45 -5
  9. package/engine/react/http-json.mjs +24 -0
  10. package/engine/react/mdx-compile.mjs +187 -3
  11. package/engine/react/measurement-css.mjs +93 -1
  12. package/engine/react/object-entities.mjs +119 -0
  13. package/engine/react/pipeline/allocate.mjs +10 -7
  14. package/engine/react/pipeline/frame-measurement.mjs +40 -9
  15. package/engine/react/project-asset-endpoint.mjs +6 -24
  16. package/engine/react/source-edit-endpoint.d.mts +10 -0
  17. package/engine/react/source-edit-endpoint.mjs +75 -0
  18. package/engine/react/sources/mdx-resolver.mjs +12 -14
  19. package/engine/react/style-discovery.mjs +1 -4
  20. package/engine/runtime/file-walk.mjs +22 -0
  21. package/engine/runtime/inspection.mjs +1 -20
  22. package/engine/runtime/path-utils.mjs +20 -0
  23. package/engine/runtime/source-text-tools.d.mts +102 -0
  24. package/engine/runtime/source-text-tools.mjs +551 -16
  25. package/engine/runtime/source-workspace.mjs +4 -31
  26. package/package.json +1 -1
  27. package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
  28. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
  29. package/src/openpress/app/index.ts +2 -0
  30. package/src/openpress/core/Frame.tsx +9 -11
  31. package/src/openpress/core/FrameContext.tsx +8 -3
  32. package/src/openpress/core/MdxArea.tsx +11 -12
  33. package/src/openpress/core/cn.ts +4 -0
  34. package/src/openpress/core/index.tsx +2 -1
  35. package/src/openpress/core/primitives.tsx +29 -8
  36. package/src/openpress/core/types.ts +8 -0
  37. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  38. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  39. package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
  40. package/src/openpress/document-model/index.ts +6 -0
  41. package/src/openpress/document-model/objectEntityModel.ts +51 -0
  42. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  43. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  44. package/src/openpress/manuscript/index.tsx +49 -7
  45. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  46. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  47. package/src/openpress/reader/index.ts +10 -0
  48. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  49. package/src/openpress/reader/readerTypes.ts +4 -0
  50. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  51. package/src/openpress/reader/usePanelState.ts +56 -0
  52. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  53. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  54. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  55. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  56. package/src/openpress/shared/Panel.tsx +77 -0
  57. package/src/openpress/shared/index.ts +4 -0
  58. package/src/openpress/shared/numberUtils.ts +3 -0
  59. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  60. package/src/openpress/workbench/Workbench.tsx +407 -0
  61. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  62. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  63. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  64. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  65. package/src/openpress/workbench/actions/index.ts +5 -0
  66. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  67. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  68. package/src/openpress/workbench/dialog/index.ts +1 -0
  69. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  70. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  71. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  72. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  73. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  74. package/src/openpress/workbench/document/index.ts +10 -0
  75. package/src/openpress/workbench/index.ts +2 -0
  76. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  77. package/src/openpress/workbench/inspector/index.ts +5 -0
  78. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  79. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  80. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  81. package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
  82. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  83. package/src/openpress/workbench/mentions/index.ts +2 -0
  84. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  85. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  86. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
  87. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  88. package/src/openpress/workbench/panels/index.ts +3 -0
  89. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
  90. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  91. package/src/openpress/workbench/project/index.ts +2 -0
  92. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  93. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  94. package/src/openpress/workbench/shell/index.ts +1 -0
  95. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  96. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  97. package/src/styles/openpress/print-route.css +0 -2
  98. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  99. package/src/styles/openpress/public-viewer.css +25 -320
  100. package/src/styles/openpress/reader-runtime.css +243 -55
  101. package/src/styles/openpress/responsive.css +145 -270
  102. package/src/styles/openpress/workbench-panels.css +214 -178
  103. package/src/styles/openpress/workbench.css +986 -451
  104. package/src/styles/openpress.css +1 -1
  105. package/vite.config.ts +50 -0
  106. package/src/openpress/inspector.ts +0 -282
  107. package/src/openpress/projectWorkspace.tsx +0 -919
  108. package/src/openpress/readerRuntime.ts +0 -230
  109. package/src/openpress/workbench.tsx +0 -1265
  110. package/src/openpress/workbenchTypes.ts +0 -4
  111. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  112. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  113. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  114. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  115. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  116. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -1,1265 +0,0 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useLayoutEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- type CSSProperties,
9
- type FormEvent,
10
- type RefObject,
11
- } from "react";
12
- import { ArrowUp, BookOpen, ExternalLink, Eye, FileText, FolderOpen, MessageSquare, MousePointer2, Pencil, Plus, RefreshCw, Rocket, Trash2, X } from "lucide-react";
13
- import {
14
- collectBookmarkIndex,
15
- collectMediaAssetIndex,
16
- } from "./indexes";
17
- import { appendComposerToken, useComposerMentions } from "./composerMentions";
18
- import {
19
- clearInspectorComment,
20
- fetchInspectorComments,
21
- submitInspectorComment,
22
- updateInspectorComment,
23
- useInspector,
24
- type InspectorIntent,
25
- type InspectorPlacement,
26
- type InspectorState,
27
- type InspectorTarget,
28
- type PendingComment,
29
- } from "./inspector";
30
- import {
31
- createProjectMentionItems,
32
- createProjectComponentUsages,
33
- ProjectEntryPanel,
34
- type ProjectMentionItem,
35
- } from "./projectWorkspace";
36
- import { createAnchorPageMap, resolveAnchorPageIndex } from "./anchorMap";
37
- import { scheduleBrowserFrame } from "./frameScheduler";
38
- import {
39
- PUBLIC_DRAWER_BREAKPOINT,
40
- PublicPage,
41
- useViewMode,
42
- } from "./publicPage";
43
- import { getProjectIdentity } from "./projectIdentity";
44
- import { buildPublicPreviewHref, isLocalWorkspaceHost } from "./runtimeMode";
45
- import { useReaderRuntime } from "./readerRuntime";
46
- import type { DeploymentInfo, ReaderDocument, HtmlPageBlock, SourceBlock } from "./types";
47
- import { Bookmarks, CurrentPagePanel } from "./workbenchPanels";
48
- import type { DisplayPage } from "./workbenchTypes";
49
-
50
- type WorkspaceView = "document" | "project" | "comments";
51
- type DeployStatus = "idle" | "deploying" | "deployed" | "unavailable" | "failed" | "setup";
52
- type PdfActionStatus = "idle" | "generating" | "opening" | "failed";
53
- type InspectorCommentStatus = "idle" | "submitting" | "saved" | "failed";
54
- type CommentsWorkspaceStatus = "idle" | "loading" | "ready" | "failed" | "clearing";
55
-
56
- interface InlineSavedComment {
57
- id: string;
58
- blockId: string;
59
- placement: InspectorPlacement;
60
- note: string;
61
- path?: string;
62
- line?: number;
63
- timestamp?: string;
64
- }
65
-
66
- function getInitialWorkspaceView(): WorkspaceView {
67
- if (typeof window === "undefined") return "document";
68
- const workspace = new URLSearchParams(window.location.search).get("workspace");
69
- if (workspace === "project") return "project";
70
- if (workspace === "comments") return "comments";
71
- return "document";
72
- }
73
-
74
- function WorkspaceSwitcher({
75
- workspaceView,
76
- onOpenWorkspace,
77
- }: {
78
- workspaceView: WorkspaceView;
79
- onOpenWorkspace: (view: WorkspaceView) => void;
80
- }) {
81
- const items: Array<{ view: WorkspaceView; label: string; icon: typeof FileText }> = [
82
- { view: "document", label: "文件", icon: FileText },
83
- { view: "project", label: "專案", icon: FolderOpen },
84
- { view: "comments", label: "註解", icon: MessageSquare },
85
- ];
86
-
87
- return (
88
- <nav className="openpress-dev-workspace-switcher" data-openpress-dev-workspace-switcher aria-label="Workspace">
89
- {items.map((item) => {
90
- const Icon = item.icon;
91
- return (
92
- <button
93
- type="button"
94
- className={workspaceView === item.view ? "is-active" : ""}
95
- aria-pressed={workspaceView === item.view}
96
- onClick={() => onOpenWorkspace(item.view)}
97
- key={item.view}
98
- >
99
- <Icon aria-hidden="true" />
100
- <span>{item.label}</span>
101
- </button>
102
- );
103
- })}
104
- </nav>
105
- );
106
- }
107
-
108
- export function HtmlWorkbench({
109
- document,
110
- pages,
111
- style,
112
- devMode,
113
- deploymentInfo,
114
- }: {
115
- document: ReaderDocument;
116
- pages: Array<HtmlPageBlock>;
117
- style: CSSProperties;
118
- devMode: boolean;
119
- deploymentInfo: DeploymentInfo;
120
- }) {
121
- const sourceContainerRef = useRef<HTMLDivElement | null>(null);
122
- const displayPages = pages;
123
- const viewModeState = useViewMode();
124
- const { viewMode } = viewModeState;
125
- const mediaAssets = useMemo(() => collectMediaAssetIndex(displayPages), [displayPages]);
126
- const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
127
- const projectComponentUsages = useMemo(() => createProjectComponentUsages(displayPages), [displayPages]);
128
- const bookmarks = useMemo(() => collectBookmarkIndex(displayPages), [displayPages]);
129
- const projectMentionItems = useMemo(
130
- () => createProjectMentionItems(mediaAssets, projectComponentUsages, bookmarks),
131
- [bookmarks, mediaAssets, projectComponentUsages],
132
- );
133
- const [workspaceView, setWorkspaceView] = useState<WorkspaceView>(getInitialWorkspaceView);
134
- const inspector = useInspector(document, { enabled: devMode && (workspaceView === "document" || workspaceView === "project") });
135
- const reader = useReaderRuntime({ pageCount: Math.max(displayPages.length, 1), rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT });
136
- const [deployStatus, setDeployStatus] = useState<DeployStatus>("idle");
137
- const [pdfActionStatus, setPdfActionStatus] = useState<PdfActionStatus>("idle");
138
- const [inspectorCommentText, setInspectorCommentText] = useState("");
139
- const [inspectorCommentStatus, setInspectorCommentStatus] = useState<InspectorCommentStatus>("idle");
140
- const [inspectorCommentError, setInspectorCommentError] = useState("");
141
- const [inlineSavedComment, setInlineSavedComment] = useState<InlineSavedComment | null>(null);
142
- const [pendingComments, setPendingComments] = useState<PendingComment[]>([]);
143
- const [commentsStatus, setCommentsStatus] = useState<CommentsWorkspaceStatus>("idle");
144
- const [commentsError, setCommentsError] = useState("");
145
- const [currentDeploymentInfo, setCurrentDeploymentInfo] = useState(deploymentInfo);
146
- const staticPdfHref = currentDeploymentInfo.pdf;
147
- const projectIdentity = getProjectIdentity(document.meta);
148
- const localDeployEnabled = useMemo(() => {
149
- if (typeof window === "undefined") return false;
150
- return isLocalWorkspaceHost(window.location.hostname);
151
- }, []);
152
- const deploymentStatusDescription = deploymentStatusText(currentDeploymentInfo, deployStatus);
153
- const deploymentStatusLabelText = deploymentStatusSummary(currentDeploymentInfo, deployStatus);
154
- const pdfButtonText = workbenchPdfButtonText(localDeployEnabled, pdfActionStatus, staticPdfHref);
155
- const pdfStatusMessage = workbenchPdfStatusMessage(localDeployEnabled, pdfActionStatus);
156
- const pdfButtonDisabled = localDeployEnabled ? pdfActionStatus === "generating" || pdfActionStatus === "opening" : !staticPdfHref;
157
- const inspectorSelectionLabel = formatInspectorSelection(inspector.selectedBlock);
158
- const activeInlineSavedComment = getInlineSavedCommentForTarget(inlineSavedComment, inspector.selectedTarget);
159
- const inspectorCommentDisabled = !inspector.selectedBlock || !inspectorCommentText.trim() || inspectorCommentStatus === "submitting";
160
- const inspectorCommentStatusMessage = formatInspectorCommentStatus(inspectorCommentStatus, inspectorCommentError);
161
- const publicPreviewHref = useMemo(() => {
162
- if (typeof window === "undefined") return "/";
163
- return buildPublicPreviewHref(window.location.href, reader.currentPageIndex);
164
- }, [reader.currentPageIndex]);
165
-
166
- const refreshPendingComments = useCallback(async () => {
167
- if (!devMode) return;
168
- setCommentsStatus("loading");
169
- setCommentsError("");
170
- try {
171
- const comments = await fetchInspectorComments();
172
- setPendingComments(comments);
173
- setCommentsStatus("ready");
174
- } catch (error) {
175
- setCommentsStatus("failed");
176
- setCommentsError(error instanceof Error ? error.message : String(error));
177
- }
178
- }, [devMode]);
179
-
180
- const clearPendingComment = useCallback(async (id: string) => {
181
- setCommentsStatus("clearing");
182
- setCommentsError("");
183
- try {
184
- await clearInspectorComment({ id });
185
- setPendingComments((comments) => comments.filter((comment) => comment.id !== id));
186
- setInlineSavedComment((comment) => comment?.id === id ? null : comment);
187
- setCommentsStatus("ready");
188
- } catch (error) {
189
- setCommentsStatus("failed");
190
- setCommentsError(error instanceof Error ? error.message : String(error));
191
- }
192
- }, []);
193
-
194
- const clearAllPendingComments = useCallback(async () => {
195
- setCommentsStatus("clearing");
196
- setCommentsError("");
197
- try {
198
- await clearInspectorComment({ all: true });
199
- setPendingComments([]);
200
- setInlineSavedComment(null);
201
- setCommentsStatus("ready");
202
- } catch (error) {
203
- setCommentsStatus("failed");
204
- setCommentsError(error instanceof Error ? error.message : String(error));
205
- }
206
- }, []);
207
-
208
- const handleDeploy = async () => {
209
- if (deployStatus === "deploying") return;
210
- if (currentDeploymentInfo.configured === false) {
211
- setDeployStatus("setup");
212
- return;
213
- }
214
- setDeployStatus("deploying");
215
- try {
216
- const response = await fetch("/__openpress/deploy", { method: "POST" });
217
- if (response.status === 404 || response.status === 405) {
218
- setDeployStatus("unavailable");
219
- return;
220
- }
221
- if (!response.ok) {
222
- const text = await response.text().catch(() => "");
223
- const result = parseDeployError(text);
224
- if (result?.deploy_configured === false) {
225
- setCurrentDeploymentInfo((info) => ({
226
- ...info,
227
- configured: false,
228
- adapter: result.deploy_adapter ?? info.adapter,
229
- source: result.deploy_source ?? info.source,
230
- projectName: result.deploy_project_name ?? info.projectName,
231
- setupMessage: result.message ?? info.setupMessage,
232
- }));
233
- setDeployStatus("setup");
234
- return;
235
- }
236
- console.error("OpenPress deploy failed", text);
237
- setDeployStatus("failed");
238
- return;
239
- }
240
- const result = (await response.json().catch(() => null)) as { deployed_at?: string; pdf?: string; public_url?: string } | null;
241
- setCurrentDeploymentInfo((info) => ({
242
- online: true,
243
- deployedAt: result?.deployed_at ?? new Date().toISOString(),
244
- pdf: result?.pdf ?? info.pdf ?? __OPENPRESS_PDF_HREF__,
245
- publicUrl: result?.public_url ?? info.publicUrl,
246
- dirty: false,
247
- }));
248
- setDeployStatus("deployed");
249
- setTimeout(() => setDeployStatus("idle"), 3200);
250
- } catch (error) {
251
- console.error("OpenPress deploy unavailable", error);
252
- setDeployStatus("unavailable");
253
- }
254
- };
255
-
256
- const handleOpenLatestLocalPdf = async () => {
257
- if (pdfActionStatus === "generating") return;
258
- setPdfActionStatus("generating");
259
- try {
260
- const response = await fetch("/__openpress/local-pdf-export", { method: "POST" });
261
- if (!response.ok) {
262
- const text = await response.text().catch(() => "");
263
- throw new Error(text || `Local PDF export failed with status ${response.status}`);
264
- }
265
- const result = (await response.json().catch(() => null)) as { pdf?: string } | null;
266
- const pdfHref = result?.pdf ?? "/__openpress/local-pdf-file";
267
- setPdfActionStatus("opening");
268
- window.setTimeout(() => window.location.assign(pdfHref), 180);
269
- } catch (error) {
270
- console.error("OpenPress local PDF export failed", error);
271
- setPdfActionStatus("failed");
272
- }
273
- };
274
-
275
- const handleOpenWorkbenchPdf = () => {
276
- if (localDeployEnabled) {
277
- void handleOpenLatestLocalPdf();
278
- return;
279
- }
280
- if (!staticPdfHref) return;
281
- window.open(staticPdfHref, "_blank", "noopener,noreferrer");
282
- };
283
-
284
- const handleSubmitInspectorComment = async (event?: FormEvent<HTMLFormElement>) => {
285
- event?.preventDefault();
286
- if (inspectorCommentDisabled || !inspector.selectedBlock) return;
287
- setInspectorCommentStatus("submitting");
288
- setInspectorCommentError("");
289
- try {
290
- const note = inspectorCommentText.trim();
291
- const placement = inspector.selectedTarget?.placement ?? "block";
292
- if (activeInlineSavedComment) {
293
- const result = await updateInspectorComment({
294
- id: activeInlineSavedComment.id,
295
- note,
296
- intent: "edit",
297
- placement,
298
- });
299
- setInlineSavedComment({
300
- ...activeInlineSavedComment,
301
- note,
302
- path: result.comment?.path ?? activeInlineSavedComment.path,
303
- line: result.comment?.line ?? activeInlineSavedComment.line,
304
- timestamp: result.comment?.timestamp ?? activeInlineSavedComment.timestamp,
305
- });
306
- } else {
307
- const result = await submitInspectorComment({
308
- block: inspector.selectedBlock,
309
- note,
310
- intent: inspector.commentIntent,
311
- placement,
312
- });
313
- if (result.comment?.id && inspector.selectedTarget) {
314
- setInlineSavedComment({
315
- id: result.comment.id,
316
- blockId: inspector.selectedTarget.blockId,
317
- placement,
318
- note,
319
- path: result.comment.path,
320
- line: result.comment.line,
321
- timestamp: result.comment.timestamp,
322
- });
323
- }
324
- }
325
- setInspectorCommentText("");
326
- setInspectorCommentStatus("saved");
327
- void refreshPendingComments();
328
- } catch (error) {
329
- setInspectorCommentStatus("failed");
330
- setInspectorCommentError(error instanceof Error ? error.message : String(error));
331
- }
332
- };
333
-
334
- const handleOpenInlineSavedComment = (comment: InlineSavedComment) => {
335
- setInlineSavedComment(comment);
336
- setInspectorCommentText(comment.note);
337
- setInspectorCommentStatus("idle");
338
- setInspectorCommentError("");
339
- inspector.setCommentIntent("edit");
340
- };
341
-
342
- const handleRemoveInlineSavedComment = async (comment: InlineSavedComment) => {
343
- setInspectorCommentStatus("submitting");
344
- setInspectorCommentError("");
345
- try {
346
- await clearInspectorComment({ id: comment.id });
347
- setPendingComments((comments) => comments.filter((item) => item.id !== comment.id));
348
- setInlineSavedComment((current) => current?.id === comment.id ? null : current);
349
- setInspectorCommentText("");
350
- setInspectorCommentStatus("idle");
351
- inspector.selectTarget(null);
352
- void refreshPendingComments();
353
- } catch (error) {
354
- setInspectorCommentStatus("failed");
355
- setInspectorCommentError(error instanceof Error ? error.message : String(error));
356
- }
357
- };
358
-
359
- const selectWorkspacePage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
360
- reader.setPage(pageIndex, options);
361
- if (typeof window !== "undefined" && window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && reader.rightPanelOpen) {
362
- reader.toggleRightPanel();
363
- }
364
- };
365
-
366
- const selectWorkspaceAnchor = (anchorId: string, pageIndex?: number) => {
367
- const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex);
368
- if (targetPageIndex === null) return false;
369
- selectWorkspacePage(targetPageIndex, { behavior: "smooth" });
370
- return true;
371
- };
372
-
373
- const insertProjectMention = useCallback((mention: string) => {
374
- setInspectorCommentText((text) => appendComposerToken(text, mention));
375
- setInspectorCommentStatus("idle");
376
- setInspectorCommentError("");
377
- }, []);
378
-
379
- const openWorkspace = (view: WorkspaceView) => {
380
- setWorkspaceView(view);
381
- if (typeof window === "undefined") return;
382
- scheduleBrowserFrame(() => reader.setPage(reader.currentPageIndex, { behavior: "auto" }));
383
- };
384
-
385
- useEffect(() => {
386
- setInspectorCommentStatus("idle");
387
- setInspectorCommentError("");
388
- setInspectorCommentText("");
389
- }, [inspector.selectedBlockId, inspector.selectedTarget?.placement]);
390
-
391
- useEffect(() => {
392
- if (!devMode || workspaceView !== "comments") return;
393
- void refreshPendingComments();
394
- }, [devMode, refreshPendingComments, workspaceView]);
395
-
396
- const actionSection = (
397
- <section className="openpress-public-action-section" aria-label="輸出">
398
- <span className="openpress-public-action-heading">輸出</span>
399
- <div className="openpress-public-action-list" aria-label="輸出操作">
400
- <a
401
- className="openpress-public-action-entry openpress-public-preview-link"
402
- data-openpress-open-public-preview
403
- href={publicPreviewHref}
404
- target="_blank"
405
- rel="noreferrer"
406
- aria-label="開啟公開預覽"
407
- >
408
- <Eye aria-hidden="true" />
409
- <span className="openpress-public-action-entry__label">公開預覽</span>
410
- </a>
411
- <button
412
- type="button"
413
- className="openpress-public-action-entry"
414
- data-openpress-public-export
415
- disabled={pdfButtonDisabled}
416
- onClick={handleOpenWorkbenchPdf}
417
- >
418
- <ExternalLink aria-hidden="true" />
419
- <span className="openpress-public-action-entry__label">{pdfButtonText}</span>
420
- {pdfStatusMessage ? (
421
- <span className="openpress-dev-pdf-status" data-openpress-pdf-status={pdfActionStatus} role="status" aria-live="polite">
422
- <span className="openpress-dev-pdf-status__spinner" aria-hidden="true" />
423
- <span>{pdfStatusMessage}</span>
424
- </span>
425
- ) : null}
426
- </button>
427
- {devMode && (workspaceView === "document" || workspaceView === "project") ? (
428
- <button
429
- type="button"
430
- className="openpress-public-action-entry"
431
- data-openpress-inspector-toggle
432
- data-openpress-inspector-active={inspector.inspectorMode ? "true" : "false"}
433
- onClick={inspector.toggleInspectorMode}
434
- aria-pressed={inspector.inspectorMode}
435
- title={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
436
- aria-label={inspector.inspectorMode ? "關閉註解" : "開啟註解"}
437
- >
438
- <MousePointer2 aria-hidden="true" />
439
- <span className="openpress-public-action-entry__label">{inspector.inspectorMode ? "註解中" : "註解"}</span>
440
- <span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
441
- </button>
442
- ) : null}
443
- {devMode && (workspaceView === "document" || workspaceView === "project") && inspector.inspectorMode ? (
444
- <span className="openpress-dev-inspector-status" role="status" aria-live="polite" data-openpress-inspector-comment-status={inspectorCommentStatus}>
445
- {inspectorCommentStatusMessage}
446
- </span>
447
- ) : null}
448
- {localDeployEnabled ? (
449
- <button
450
- type="button"
451
- className="openpress-public-action-entry"
452
- data-openpress-deploy
453
- data-openpress-deploy-status={deploymentStatusKind(currentDeploymentInfo, deployStatus)}
454
- data-deploy-status={deployStatus}
455
- disabled={deployStatus === "deploying" || deployStatus === "unavailable" || currentDeploymentInfo.configured === false}
456
- onClick={handleDeploy}
457
- title={deploymentStatusDescription}
458
- aria-label={deploymentStatusDescription}
459
- >
460
- <Rocket aria-hidden="true" />
461
- <span className="openpress-public-action-entry__label">{deployButtonText(currentDeploymentInfo, deployStatus)}</span>
462
- <span
463
- className="openpress-dev-deploy-status"
464
- data-openpress-deploy-status={deploymentStatusKind(currentDeploymentInfo, deployStatus)}
465
- role="status"
466
- aria-live="polite"
467
- >
468
- <span className="openpress-dev-deploy-status__dot" aria-hidden="true" />
469
- <span>{deploymentStatusLabelText}</span>
470
- </span>
471
- </button>
472
- ) : null}
473
- </div>
474
- </section>
475
- );
476
-
477
- return (
478
- <main className="openpress-workbench" style={style} data-dev-mode={devMode ? "true" : "false"}>
479
- <div
480
- className={`reader-app openpress-reader-app openpress-public-viewer openpress-dev-public-viewer is-ready${reader.rightPanelOpen ? "" : " is-closed-right"}`}
481
- data-openpress-react-runtime="true"
482
- data-openpress-view-mode={viewMode}
483
- data-openpress-inspector-mode={inspector.inspectorMode ? "on" : "off"}
484
- data-active-workspace={workspaceView}
485
- >
486
- {reader.rightPanelOpen ? (
487
- <div className="openpress-public-scrim" aria-hidden="true" onClick={reader.toggleRightPanel} />
488
- ) : null}
489
- <button type="button" className="openpress-public-fab" aria-label="開啟目錄" onClick={reader.toggleRightPanel}>
490
- <BookOpen size={20} aria-hidden="true" />
491
- </button>
492
-
493
- <section
494
- className="openpress-workbench__stage openpress-public-viewer__stage openpress-dev-main-content"
495
- aria-label="Workspace content"
496
- data-workspace-view={workspaceView}
497
- >
498
- <main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
499
- <PublicPage
500
- pages={displayPages}
501
- currentPageIndex={reader.currentPageIndex}
502
- devMode={devMode}
503
- sourceContainerRef={sourceContainerRef}
504
- registerPage={reader.registerPage}
505
- exposeSourceData={devMode}
506
- inspector={inspector}
507
- onInternalAnchorNavigate={selectWorkspaceAnchor}
508
- />
509
- {devMode && (workspaceView === "document" || workspaceView === "project") ? (
510
- <InlineInspectorLayer
511
- sourceContainerRef={sourceContainerRef}
512
- inspector={inspector}
513
- savedComment={activeInlineSavedComment}
514
- commentText={inspectorCommentText}
515
- commentStatus={inspectorCommentStatus}
516
- commentStatusMessage={inspectorCommentStatusMessage}
517
- submitDisabled={inspectorCommentDisabled}
518
- mentionItems={projectMentionItems}
519
- onOpenSavedComment={handleOpenInlineSavedComment}
520
- onRemoveSavedComment={handleRemoveInlineSavedComment}
521
- onCommentTextChange={setInspectorCommentText}
522
- onSubmitComment={handleSubmitInspectorComment}
523
- />
524
- ) : null}
525
- </main>
526
- </section>
527
-
528
- <aside className="reader-side-nav openpress-workspace-panel openpress-public-navigation openpress-dev-public-navigation" aria-label="Workspace panel">
529
- <button type="button" className="openpress-public-drawer-close" aria-label="關閉目錄" onClick={reader.toggleRightPanel}>
530
- <X size={16} aria-hidden="true" />
531
- </button>
532
- <section className="openpress-public-identity" aria-label="文件資訊">
533
- <strong>
534
- <span className="openpress-public-title-main">{projectIdentity.name}</span>
535
- {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
536
- </strong>
537
- {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
538
- </section>
539
- <div className="openpress-dev-public-tools" aria-label="Workspace">
540
- <WorkspaceSwitcher workspaceView={workspaceView} onOpenWorkspace={openWorkspace} />
541
- </div>
542
- {workspaceView === "document" ? (
543
- <>
544
- <section id="openpress-bookmarks" className="openpress-panel-section openpress-panel-section--bookmarks" aria-label="章節書籤">
545
- <nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
546
- <div className="reader-bookmarks-rail" aria-hidden="true" />
547
- <Bookmarks items={bookmarks} currentPageIndex={reader.currentPageIndex} onSelectPage={selectWorkspacePage} />
548
- </nav>
549
- </section>
550
- {actionSection}
551
- <CurrentPagePanel
552
- currentPageLabel={reader.currentPageLabel}
553
- totalPageLabel={reader.totalPageLabel}
554
- progressPercent={reader.progressPercent}
555
- title={displayPages[reader.currentPageIndex]?.title || document.meta.title}
556
- pageLabelPrefix={viewMode === "reading" ? "節" : "頁"}
557
- showHeading={false}
558
- showTitle={false}
559
- />
560
- </>
561
- ) : null}
562
- {workspaceView === "project" ? (
563
- <>
564
- <ProjectEntryPanel
565
- mediaAssets={mediaAssets}
566
- componentUsages={projectComponentUsages}
567
- mentionItems={projectMentionItems}
568
- currentSource={displayPages[reader.currentPageIndex]?.source}
569
- onInsertMention={insertProjectMention}
570
- />
571
- {actionSection}
572
- </>
573
- ) : null}
574
- {workspaceView === "comments" ? (
575
- <>
576
- <CommentsWorkspace
577
- comments={pendingComments}
578
- status={commentsStatus}
579
- error={commentsError}
580
- onRefresh={refreshPendingComments}
581
- onClear={clearPendingComment}
582
- onClearAll={clearAllPendingComments}
583
- panel
584
- />
585
- {actionSection}
586
- </>
587
- ) : null}
588
- </aside>
589
- </div>
590
- </main>
591
- );
592
- }
593
-
594
- interface InspectorLayerRect {
595
- top: number;
596
- left: number;
597
- width: number;
598
- height: number;
599
- }
600
-
601
- interface InspectorInsertTargetView {
602
- blockId: string;
603
- rect: InspectorLayerRect;
604
- }
605
-
606
- const INSPECTOR_INTENTS: Array<{ intent: InspectorIntent; label: string; icon: typeof Plus }> = [
607
- { intent: "add", label: "Add", icon: Plus },
608
- { intent: "edit", label: "Edit", icon: Pencil },
609
- { intent: "delete", label: "Remove", icon: Trash2 },
610
- ];
611
-
612
- function InlineInspectorLayer({
613
- sourceContainerRef,
614
- inspector,
615
- savedComment,
616
- commentText,
617
- commentStatus,
618
- commentStatusMessage,
619
- submitDisabled,
620
- mentionItems,
621
- onOpenSavedComment,
622
- onRemoveSavedComment,
623
- onCommentTextChange,
624
- onSubmitComment,
625
- }: {
626
- sourceContainerRef: RefObject<HTMLDivElement | null>;
627
- inspector: InspectorState;
628
- savedComment: InlineSavedComment | null;
629
- commentText: string;
630
- commentStatus: InspectorCommentStatus;
631
- commentStatusMessage: string;
632
- submitDisabled: boolean;
633
- mentionItems: ProjectMentionItem[];
634
- onOpenSavedComment: (comment: InlineSavedComment) => void;
635
- onRemoveSavedComment: (comment: InlineSavedComment) => Promise<void>;
636
- onCommentTextChange: (value: string) => void;
637
- onSubmitComment: (event?: FormEvent<HTMLFormElement>) => Promise<void>;
638
- }) {
639
- const textareaRef = useRef<HTMLTextAreaElement | null>(null);
640
- const rafRef = useRef<number | null>(null);
641
- const active = inspector.enabled && inspector.inspectorMode;
642
- const selectedTarget = inspector.selectedTarget;
643
- const selectedTargetKey = selectedTarget ? `${selectedTarget.blockId}:${selectedTarget.placement}` : null;
644
- const savedCommentForTarget = getInlineSavedCommentForTarget(savedComment, selectedTarget);
645
- const [insertTargets, setInsertTargets] = useState<InspectorInsertTargetView[]>([]);
646
- const [selectionRect, setSelectionRect] = useState<InspectorLayerRect | null>(null);
647
- const [composerTargetKey, setComposerTargetKey] = useState<string | null>(null);
648
- const composerOpen = Boolean(selectedTargetKey && composerTargetKey === selectedTargetKey);
649
- const markerOnly = Boolean(savedCommentForTarget && !composerOpen);
650
- const {
651
- activeMention,
652
- handleMentionKeyDown,
653
- highlightedMentionIndex,
654
- mentionSuggestions,
655
- setHighlightedMentionIndex,
656
- setComposerCursor,
657
- syncCursor,
658
- insertMention,
659
- } = useComposerMentions({
660
- text: commentText,
661
- items: mentionItems,
662
- textareaRef,
663
- onTextChange: onCommentTextChange,
664
- enabled: composerOpen,
665
- });
666
-
667
- const updateLayer = useCallback(() => {
668
- const root = sourceContainerRef.current;
669
- if (!active || !root) {
670
- setInsertTargets([]);
671
- setSelectionRect(null);
672
- if (root) syncInspectorSelectedBlock(root, null);
673
- return;
674
- }
675
-
676
- const blockElements = collectInspectorBlockElements(root);
677
- const nextInsertTargets = createInspectorInsertTargets(blockElements);
678
- setInsertTargets(nextInsertTargets);
679
- setSelectionRect(resolveInspectorSelectionRect(root, selectedTarget, nextInsertTargets));
680
- syncInspectorSelectedBlock(root, markerOnly ? null : selectedTarget);
681
- }, [active, markerOnly, selectedTarget, sourceContainerRef]);
682
-
683
- const scheduleLayerUpdate = useCallback(() => {
684
- if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
685
- rafRef.current = window.requestAnimationFrame(() => {
686
- rafRef.current = null;
687
- updateLayer();
688
- });
689
- }, [updateLayer]);
690
-
691
- useLayoutEffect(() => {
692
- updateLayer();
693
- }, [updateLayer]);
694
-
695
- useEffect(() => {
696
- if (!active) return undefined;
697
- const root = sourceContainerRef.current;
698
- const resizeObserver = typeof ResizeObserver === "undefined" || !root
699
- ? null
700
- : new ResizeObserver(scheduleLayerUpdate);
701
- if (root && resizeObserver) resizeObserver.observe(root);
702
- window.addEventListener("resize", scheduleLayerUpdate);
703
- window.addEventListener("scroll", scheduleLayerUpdate, true);
704
-
705
- return () => {
706
- resizeObserver?.disconnect();
707
- window.removeEventListener("resize", scheduleLayerUpdate);
708
- window.removeEventListener("scroll", scheduleLayerUpdate, true);
709
- if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
710
- rafRef.current = null;
711
- };
712
- }, [active, scheduleLayerUpdate, sourceContainerRef]);
713
-
714
- useEffect(() => {
715
- if (!selectedTarget || composerTargetKey !== selectedTargetKey) return;
716
- const frame = window.requestAnimationFrame(() => textareaRef.current?.focus());
717
- return () => window.cancelAnimationFrame(frame);
718
- }, [composerTargetKey, selectedTarget, selectedTargetKey]);
719
-
720
- useEffect(() => {
721
- setComposerTargetKey(null);
722
- }, [selectedTargetKey]);
723
-
724
- useEffect(() => {
725
- if (commentStatus === "saved") setComposerTargetKey(null);
726
- }, [commentStatus]);
727
-
728
- if (!active) return null;
729
-
730
- const composerStyle = selectionRect ? createInspectorComposerStyle(selectionRect, composerOpen) : undefined;
731
- const markerStyle = selectionRect ? createInspectorMarkerStyle(selectionRect) : undefined;
732
- const visibleIntentItems = savedCommentForTarget
733
- ? INSPECTOR_INTENTS.filter((item) => item.intent !== "add")
734
- : INSPECTOR_INTENTS;
735
- const chooseIntent = (intent: InspectorIntent) => {
736
- if (!selectedTargetKey) return;
737
- inspector.setCommentIntent(intent);
738
- setComposerTargetKey(selectedTargetKey);
739
- };
740
- const openSavedComment = () => {
741
- if (!selectedTargetKey || !savedCommentForTarget) return;
742
- onOpenSavedComment(savedCommentForTarget);
743
- setComposerTargetKey(selectedTargetKey);
744
- };
745
- const handleMarkerClick = () => {
746
- if (!selectedTargetKey) return;
747
- if (savedCommentForTarget) {
748
- openSavedComment();
749
- return;
750
- }
751
- setComposerTargetKey(selectedTargetKey);
752
- };
753
-
754
- return (
755
- <div className="openpress-inline-inspector-layer" data-openpress-inline-inspector-layer>
756
- {insertTargets.map((target) => {
757
- const isSelected = selectedTarget?.blockId === target.blockId && selectedTarget.placement === "before";
758
- return (
759
- <button
760
- type="button"
761
- className={`openpress-inline-insert-target${isSelected ? " is-selected" : ""}`}
762
- data-openpress-insert-before-block-id={target.blockId}
763
- style={rectToFixedStyle(target.rect)}
764
- aria-label="在此新增註解"
765
- key={target.blockId}
766
- onClick={() => inspector.selectTarget({ blockId: target.blockId, placement: "before" })}
767
- />
768
- );
769
- })}
770
-
771
- {selectionRect && selectedTarget ? (
772
- <>
773
- <button
774
- type="button"
775
- className="openpress-inline-comment-marker"
776
- data-openpress-inline-comment-marker
777
- data-openpress-marker-state={savedCommentForTarget ? "saved" : "draft"}
778
- style={markerStyle}
779
- aria-label={savedCommentForTarget ? "編輯註解 1" : "目前選取區塊 1"}
780
- onClick={handleMarkerClick}
781
- >
782
- 1
783
- </button>
784
- {!markerOnly ? (
785
- <form
786
- className="openpress-inline-comment-composer"
787
- data-openpress-inline-comment-composer
788
- data-openpress-comment-placement={selectedTarget.placement}
789
- data-openpress-comment-intent={inspector.commentIntent}
790
- data-openpress-composer-open={composerOpen ? "true" : "false"}
791
- data-openpress-composer-saved={savedCommentForTarget ? "true" : "false"}
792
- style={composerStyle}
793
- onSubmit={(event) => void onSubmitComment(event)}
794
- >
795
- <div className="openpress-inline-comment-composer__intents" aria-label="註解意圖">
796
- {visibleIntentItems.map((item) => {
797
- const Icon = item.icon;
798
- return (
799
- <button
800
- type="button"
801
- className={composerOpen && inspector.commentIntent === item.intent ? "is-active" : ""}
802
- aria-label={item.label}
803
- title={item.label}
804
- aria-pressed={composerOpen && inspector.commentIntent === item.intent}
805
- key={item.intent}
806
- onClick={() => {
807
- if (savedCommentForTarget && item.intent === "delete") {
808
- void onRemoveSavedComment(savedCommentForTarget);
809
- return;
810
- }
811
- chooseIntent(item.intent);
812
- }}
813
- >
814
- <Icon aria-hidden="true" />
815
- </button>
816
- );
817
- })}
818
- </div>
819
- {composerOpen ? (
820
- <div className="openpress-inline-comment-composer__body">
821
- <textarea
822
- ref={textareaRef}
823
- value={commentText}
824
- disabled={commentStatus === "submitting"}
825
- onChange={(event) => {
826
- onCommentTextChange(event.target.value);
827
- setComposerCursor(event.target.selectionStart ?? event.target.value.length);
828
- }}
829
- onClick={syncCursor}
830
- onKeyUp={syncCursor}
831
- onKeyDown={(event) => {
832
- if (handleMentionKeyDown(event)) return;
833
- if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
834
- event.preventDefault();
835
- void onSubmitComment();
836
- }
837
- }}
838
- aria-label={savedCommentForTarget ? "編輯註解" : "新增註解"}
839
- placeholder="新增註解..."
840
- rows={3}
841
- />
842
- <button type="submit" disabled={submitDisabled} aria-label="送出註解">
843
- <ArrowUp aria-hidden="true" />
844
- </button>
845
- </div>
846
- ) : null}
847
- {composerOpen && mentionSuggestions.length > 0 ? (
848
- <div className="openpress-inline-comment-composer__suggestions" role="listbox" aria-label={activeMention?.trigger === "/" ? "Skill suggestions" : "Mention suggestions"}>
849
- {mentionSuggestions.map((item, index) => (
850
- <button
851
- type="button"
852
- role="option"
853
- aria-selected={index === highlightedMentionIndex}
854
- data-highlighted={index === highlightedMentionIndex ? "true" : undefined}
855
- key={`${item.kind}-${item.value}`}
856
- onMouseDown={(event) => event.preventDefault()}
857
- onMouseEnter={() => setHighlightedMentionIndex(index)}
858
- onClick={() => insertMention(item)}
859
- >
860
- <span>{item.label}</span>
861
- <small>{item.meta}</small>
862
- </button>
863
- ))}
864
- </div>
865
- ) : null}
866
- {composerOpen && commentStatusMessage ? (
867
- <p role="status" aria-live="polite" data-openpress-inspector-comment-status={commentStatus}>
868
- {commentStatusMessage}
869
- </p>
870
- ) : null}
871
- </form>
872
- ) : null}
873
- </>
874
- ) : null}
875
- </div>
876
- );
877
- }
878
-
879
- function CommentsWorkspace({
880
- comments,
881
- status,
882
- error,
883
- onRefresh,
884
- onClear,
885
- onClearAll,
886
- panel = false,
887
- }: {
888
- comments: PendingComment[];
889
- status: CommentsWorkspaceStatus;
890
- error: string;
891
- onRefresh: () => Promise<void>;
892
- onClear: (id: string) => Promise<void>;
893
- onClearAll: () => Promise<void>;
894
- panel?: boolean;
895
- }) {
896
- const busy = status === "loading" || status === "clearing";
897
-
898
- return (
899
- <section
900
- className={`openpress-comments-workspace${panel ? " openpress-comments-workspace--panel" : ""}`}
901
- data-openpress-comments-workspace
902
- data-openpress-comments-panel={panel ? "true" : undefined}
903
- aria-label="待處理註解"
904
- >
905
- <header className="openpress-comments-workspace__header">
906
- <div>
907
- <span className="openpress-comments-workspace__eyebrow">Comments</span>
908
- <h1>待處理註解</h1>
909
- <p>{formatCommentsCount(comments.length, status)}</p>
910
- </div>
911
- <div className="openpress-comments-workspace__actions" aria-label="註解操作">
912
- <button type="button" onClick={() => void onRefresh()} disabled={busy}>
913
- <RefreshCw aria-hidden="true" />
914
- <span>{status === "loading" ? "讀取中" : "重新整理"}</span>
915
- </button>
916
- <button type="button" onClick={() => void onClearAll()} disabled={busy || comments.length === 0}>
917
- <Trash2 aria-hidden="true" />
918
- <span>{status === "clearing" ? "清除中" : "清空全部"}</span>
919
- </button>
920
- </div>
921
- </header>
922
-
923
- {error ? (
924
- <p className="openpress-comments-workspace__error" role="alert">
925
- {error}
926
- </p>
927
- ) : null}
928
-
929
- {comments.length === 0 && status !== "loading" ? (
930
- <div className="openpress-comments-workspace__empty" role="status">
931
- 目前沒有註解
932
- </div>
933
- ) : (
934
- <ol className="openpress-comments-list" aria-label="待處理註解列表">
935
- {comments.map((comment) => {
936
- const hintMeta = parseCommentHint(comment.hint);
937
- return (
938
- <li className="openpress-comment-entry" data-openpress-comment-id={comment.id} key={comment.id}>
939
- <div className="openpress-comment-entry__body">
940
- <div className="openpress-comment-entry__topline">
941
- <p className="openpress-comment-entry__note">{comment.note}</p>
942
- {hintMeta ? (
943
- <span className="openpress-comment-entry__intent" data-openpress-comment-intent={hintMeta.intent}>
944
- {hintMeta.intentLabel}
945
- </span>
946
- ) : null}
947
- </div>
948
- <p className="openpress-comment-entry__meta">
949
- <code>{comment.path}:{comment.line}</code>
950
- {comment.timestamp ? <span>{formatCommentTimestamp(comment.timestamp)}</span> : null}
951
- </p>
952
- {hintMeta ? (
953
- <p className="openpress-comment-entry__hint">
954
- {hintMeta.placementLabel}
955
- </p>
956
- ) : comment.hint ? (
957
- <p className="openpress-comment-entry__hint">{comment.hint}</p>
958
- ) : null}
959
- </div>
960
- <button
961
- type="button"
962
- onClick={() => void onClear(comment.id)}
963
- disabled={busy}
964
- aria-label={`清除註解 ${comment.id}`}
965
- >
966
- <Trash2 aria-hidden="true" />
967
- <span>清除</span>
968
- </button>
969
- </li>
970
- );
971
- })}
972
- </ol>
973
- )}
974
- </section>
975
- );
976
- }
977
-
978
- function collectInspectorBlockElements(root: HTMLElement) {
979
- return Array.from(root.querySelectorAll<HTMLElement>("[data-openpress-block-id]")).filter((element) => {
980
- if (!element.dataset.openpressBlockId) return false;
981
- if (element.parentElement?.closest("[data-openpress-block-id]")) return false;
982
- const rect = element.getBoundingClientRect();
983
- return rect.width > 0 && rect.height > 0;
984
- });
985
- }
986
-
987
- function createInspectorInsertTargets(elements: HTMLElement[]): InspectorInsertTargetView[] {
988
- const targets: InspectorInsertTargetView[] = [];
989
- const seen = new Set<string>();
990
-
991
- for (let index = 1; index < elements.length; index += 1) {
992
- const previous = elements[index - 1];
993
- const current = elements[index];
994
- const blockId = current.dataset.openpressBlockId;
995
- if (!blockId || seen.has(blockId)) continue;
996
-
997
- const previousPage = previous.closest<HTMLElement>(".openpress-html-page");
998
- const currentPage = current.closest<HTMLElement>(".openpress-html-page");
999
- if (!previousPage || previousPage !== currentPage) continue;
1000
-
1001
- const previousRect = previous.getBoundingClientRect();
1002
- const currentRect = current.getBoundingClientRect();
1003
- const gap = currentRect.top - previousRect.bottom;
1004
- if (gap < 10) continue;
1005
-
1006
- const pageRect = currentPage.getBoundingClientRect();
1007
- const inset = Math.min(56, Math.max(20, pageRect.width * 0.07));
1008
- const height = Math.min(28, Math.max(14, gap - 4));
1009
- const rect = {
1010
- top: previousRect.bottom + ((gap - height) / 2),
1011
- left: pageRect.left + inset,
1012
- width: Math.max(96, pageRect.width - (inset * 2)),
1013
- height,
1014
- };
1015
- if (!isInspectorRectNearViewport(rect)) continue;
1016
-
1017
- targets.push({ blockId, rect });
1018
- seen.add(blockId);
1019
- }
1020
-
1021
- return targets;
1022
- }
1023
-
1024
- function resolveInspectorSelectionRect(
1025
- root: HTMLElement,
1026
- target: InspectorTarget | null,
1027
- insertTargets: InspectorInsertTargetView[],
1028
- ): InspectorLayerRect | null {
1029
- if (!target) return null;
1030
- if (target.placement === "before") {
1031
- const insertTarget = insertTargets.find((item) => item.blockId === target.blockId);
1032
- if (insertTarget) return insertTarget.rect;
1033
- const block = findInspectorBlockElement(root, target.blockId);
1034
- if (!block) return null;
1035
- const rect = block.getBoundingClientRect();
1036
- return {
1037
- top: rect.top - 22,
1038
- left: rect.left,
1039
- width: rect.width,
1040
- height: 22,
1041
- };
1042
- }
1043
-
1044
- const block = findInspectorBlockElement(root, target.blockId);
1045
- if (!block) return null;
1046
- const rect = block.getBoundingClientRect();
1047
- return {
1048
- top: rect.top,
1049
- left: rect.left,
1050
- width: rect.width,
1051
- height: rect.height,
1052
- };
1053
- }
1054
-
1055
- function findInspectorBlockElement(root: HTMLElement, blockId: string) {
1056
- return collectInspectorBlockElements(root).find((element) => element.dataset.openpressBlockId === blockId) ?? null;
1057
- }
1058
-
1059
- function getInlineSavedCommentForTarget(comment: InlineSavedComment | null, target: InspectorTarget | null) {
1060
- if (!comment || !target) return null;
1061
- return comment.blockId === target.blockId && comment.placement === target.placement ? comment : null;
1062
- }
1063
-
1064
- function syncInspectorSelectedBlock(root: HTMLElement, target: InspectorTarget | null) {
1065
- root.querySelectorAll<HTMLElement>('[data-openpress-inspector-selected="true"]').forEach((element) => {
1066
- delete element.dataset.openpressInspectorSelected;
1067
- });
1068
- if (!target || target.placement !== "block") return;
1069
- const selected = findInspectorBlockElement(root, target.blockId);
1070
- if (selected) selected.dataset.openpressInspectorSelected = "true";
1071
- }
1072
-
1073
- function rectToFixedStyle(rect: InspectorLayerRect): CSSProperties {
1074
- return {
1075
- top: `${rect.top}px`,
1076
- left: `${rect.left}px`,
1077
- width: `${rect.width}px`,
1078
- height: `${rect.height}px`,
1079
- };
1080
- }
1081
-
1082
- function createInspectorComposerStyle(rect: InspectorLayerRect, expanded: boolean): CSSProperties {
1083
- if (typeof window === "undefined") return {};
1084
- const targetWidth = expanded ? 460 : 292;
1085
- const width = Math.min(targetWidth, Math.max(240, window.innerWidth - 32));
1086
- const preferredLeft = rect.left + (rect.width / 2) - (width / 2);
1087
- const left = clampNumber(preferredLeft, 16, Math.max(16, window.innerWidth - width - 16));
1088
- const topAbove = rect.top - 66;
1089
- const top = topAbove > 12 ? topAbove : rect.top + rect.height + 14;
1090
- return {
1091
- top: `${top}px`,
1092
- left: `${left}px`,
1093
- width: `${width}px`,
1094
- };
1095
- }
1096
-
1097
- function createInspectorMarkerStyle(rect: InspectorLayerRect): CSSProperties {
1098
- if (typeof window === "undefined") return {};
1099
- return {
1100
- top: `${clampNumber(rect.top - 16, 8, Math.max(8, window.innerHeight - 34))}px`,
1101
- left: `${clampNumber(rect.left - 18, 8, Math.max(8, window.innerWidth - 34))}px`,
1102
- };
1103
- }
1104
-
1105
- function clampNumber(value: number, min: number, max: number) {
1106
- return Math.min(Math.max(value, min), Math.max(min, max));
1107
- }
1108
-
1109
- function isInspectorRectNearViewport(rect: InspectorLayerRect, margin = 240) {
1110
- if (typeof window === "undefined") return true;
1111
- return rect.top + rect.height >= -margin
1112
- && rect.top <= window.innerHeight + margin
1113
- && rect.left + rect.width >= -margin
1114
- && rect.left <= window.innerWidth + margin;
1115
- }
1116
-
1117
- function deployButtonText(info: DeploymentInfo, status: DeployStatus) {
1118
- if (info.configured === false || status === "setup") return "設定部署";
1119
- if (status === "deploying") return "部署中";
1120
- if (status === "failed") return "重試部署";
1121
- if (status === "unavailable") return "本機限定";
1122
- if (isDeploymentDirty(info, status)) return "重新部署";
1123
- return "部署";
1124
- }
1125
-
1126
- function workbenchPdfButtonText(localPdfEnabled: boolean, status: PdfActionStatus, staticPdfHref?: string) {
1127
- if (localPdfEnabled) {
1128
- if (status === "generating") return "產生中";
1129
- if (status === "opening") return "正在開啟";
1130
- if (status === "failed") return "重試 PDF";
1131
- return "產生 PDF";
1132
- }
1133
- return !staticPdfHref ? "PDF 未部署" : "開啟 PDF";
1134
- }
1135
-
1136
- function workbenchPdfStatusMessage(localPdfEnabled: boolean, status: PdfActionStatus) {
1137
- if (!localPdfEnabled) return null;
1138
- if (status === "generating") return "正在產生 PDF";
1139
- if (status === "opening") return "PDF 已完成,正在開啟";
1140
- if (status === "failed") return "PDF 產生失敗,請重試";
1141
- return null;
1142
- }
1143
-
1144
- function deploymentStatusKind(info: DeploymentInfo, status: DeployStatus) {
1145
- if (info.configured === false || status === "setup") return "failed";
1146
- if (status === "deploying") return "deploying";
1147
- if (status === "failed") return "failed";
1148
- if (status === "unavailable") return "unavailable";
1149
- if (isDeploymentDirty(info, status)) return "dirty";
1150
- if (status === "deployed" || hasOnlineDeployment(info)) return "online";
1151
- return "offline";
1152
- }
1153
-
1154
- function deploymentStatusLabel(info: DeploymentInfo, status: DeployStatus) {
1155
- if (info.configured === false || status === "setup") return "缺少設定";
1156
- if (status === "deploying") return "正在部署";
1157
- if (status === "failed") return "部署失敗";
1158
- if (status === "unavailable") return "本機限定";
1159
- if (isDeploymentDirty(info, status)) return "有更新";
1160
- if (status === "deployed" || hasOnlineDeployment(info)) return "已上線";
1161
- return "未上線";
1162
- }
1163
-
1164
- function deploymentStatusSummary(info: DeploymentInfo, status: DeployStatus) {
1165
- const label = deploymentStatusLabel(info, status);
1166
- if ((status === "deployed" || hasOnlineDeployment(info)) && info.deployedAt) {
1167
- return `${label} · ${formatDeployTime(info.deployedAt)}`;
1168
- }
1169
- return label;
1170
- }
1171
-
1172
- function deploymentStatusText(info: DeploymentInfo, status: DeployStatus) {
1173
- if (info.configured === false || status === "setup") {
1174
- return info.setupMessage ?? "部署設定尚未完成,請先設定 deploy.projectName";
1175
- }
1176
- if (status === "deploying") return "部署中";
1177
- if (status === "failed") return "部署失敗,請查看終端機";
1178
- if (status === "unavailable") return "目前環境沒有本地部署服務";
1179
- if (isDeploymentDirty(info, status)) return "已上線但內容有更動,點擊重新部署";
1180
- if (status === "deployed" || hasOnlineDeployment(info)) {
1181
- return `已上線${info.deployedAt ? `,更新:${formatDeployTime(info.deployedAt)}` : ""}`;
1182
- }
1183
- return "未上線";
1184
- }
1185
-
1186
- function hasOnlineDeployment(info: DeploymentInfo) {
1187
- if (info.configured === false) return false;
1188
- return Boolean(info.online || info.deployedAt || info.publicUrl || (info.pdf && /^https?:\/\//i.test(info.pdf)));
1189
- }
1190
-
1191
- function parseDeployError(text: string): {
1192
- message?: string;
1193
- deploy_configured?: boolean;
1194
- deploy_adapter?: string;
1195
- deploy_source?: string;
1196
- deploy_project_name?: string;
1197
- } | null {
1198
- try {
1199
- return JSON.parse(text) as {
1200
- message?: string;
1201
- deploy_configured?: boolean;
1202
- deploy_adapter?: string;
1203
- deploy_source?: string;
1204
- deploy_project_name?: string;
1205
- };
1206
- } catch {
1207
- return null;
1208
- }
1209
- }
1210
-
1211
- function isDeploymentDirty(info: DeploymentInfo, status: DeployStatus) {
1212
- return status === "idle" && hasOnlineDeployment(info) && info.dirty === true;
1213
- }
1214
-
1215
- function formatDeployTime(value: string) {
1216
- const date = new Date(value);
1217
- if (Number.isNaN(date.getTime())) return "時間未知";
1218
- return new Intl.DateTimeFormat("zh-TW", {
1219
- month: "2-digit",
1220
- day: "2-digit",
1221
- hour: "2-digit",
1222
- minute: "2-digit",
1223
- hour12: false,
1224
- }).format(date);
1225
- }
1226
-
1227
- function formatInspectorSelection(block: SourceBlock | null) {
1228
- if (!block) return "未選取";
1229
- const line = block.source?.line;
1230
- return line ? `${block.path}:${line}` : block.path;
1231
- }
1232
-
1233
- function formatInspectorCommentStatus(status: InspectorCommentStatus, error: string) {
1234
- if (status === "submitting") return "寫入中";
1235
- if (status === "saved") return "已寫入 source";
1236
- if (status === "failed") return error || "寫入失敗";
1237
- return "";
1238
- }
1239
-
1240
- function formatCommentsCount(count: number, status: CommentsWorkspaceStatus) {
1241
- if (status === "loading") return "正在讀取";
1242
- if (status === "clearing") return "正在清除";
1243
- return `${count} 則待處理`;
1244
- }
1245
-
1246
- function parseCommentHint(hint?: string) {
1247
- if (!hint?.startsWith("openpress-react-inspector")) return null;
1248
- const intent = hint.match(/\bintent=(add|edit|delete)\b/)?.[1] as InspectorIntent | undefined;
1249
- const placement = hint.match(/\bplacement=(block|before)\b/)?.[1] as InspectorPlacement | undefined;
1250
- const intentLabel = intent === "add" ? "Add" : intent === "delete" ? "Remove" : "Edit";
1251
- const placementLabel = placement === "before" ? "插入於區塊前" : "針對目前區塊";
1252
- return { intent: intent ?? "edit", intentLabel, placement: placement ?? "block", placementLabel };
1253
- }
1254
-
1255
- function formatCommentTimestamp(value: string) {
1256
- const date = new Date(value);
1257
- if (Number.isNaN(date.getTime())) return value;
1258
- return new Intl.DateTimeFormat("zh-TW", {
1259
- month: "2-digit",
1260
- day: "2-digit",
1261
- hour: "2-digit",
1262
- minute: "2-digit",
1263
- hour12: false,
1264
- }).format(date);
1265
- }