@open-press/core 0.7.1 → 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 (115) hide show
  1. package/engine/commands/dev.mjs +2 -2
  2. package/engine/output/chrome-pdf.mjs +18 -3
  3. package/engine/output/static-server.mjs +39 -0
  4. package/engine/react/comment-endpoint.mjs +13 -39
  5. package/engine/react/comment-marker.mjs +30 -6
  6. package/engine/react/document-entry.mjs +11 -0
  7. package/engine/react/document-export.mjs +30 -5
  8. package/engine/react/http-json.mjs +24 -0
  9. package/engine/react/mdx-compile.mjs +96 -3
  10. package/engine/react/measurement-css.mjs +93 -1
  11. package/engine/react/object-entities.mjs +119 -0
  12. package/engine/react/pipeline/allocate.mjs +10 -7
  13. package/engine/react/pipeline/frame-measurement.mjs +2 -0
  14. package/engine/react/project-asset-endpoint.mjs +6 -24
  15. package/engine/react/source-edit-endpoint.d.mts +10 -0
  16. package/engine/react/source-edit-endpoint.mjs +75 -0
  17. package/engine/react/sources/mdx-resolver.mjs +12 -14
  18. package/engine/react/style-discovery.mjs +1 -4
  19. package/engine/runtime/file-walk.mjs +22 -0
  20. package/engine/runtime/inspection.mjs +1 -20
  21. package/engine/runtime/path-utils.mjs +20 -0
  22. package/engine/runtime/source-text-tools.d.mts +102 -0
  23. package/engine/runtime/source-text-tools.mjs +551 -16
  24. package/engine/runtime/source-workspace.mjs +4 -31
  25. package/package.json +1 -1
  26. package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
  27. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
  28. package/src/openpress/app/index.ts +2 -0
  29. package/src/openpress/core/Frame.tsx +9 -11
  30. package/src/openpress/core/FrameContext.tsx +8 -3
  31. package/src/openpress/core/MdxArea.tsx +11 -12
  32. package/src/openpress/core/cn.ts +4 -0
  33. package/src/openpress/core/index.tsx +2 -1
  34. package/src/openpress/core/primitives.tsx +29 -8
  35. package/src/openpress/core/types.ts +8 -0
  36. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  37. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  38. package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
  39. package/src/openpress/document-model/index.ts +6 -0
  40. package/src/openpress/document-model/objectEntityModel.ts +51 -0
  41. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  42. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  43. package/src/openpress/manuscript/index.tsx +49 -7
  44. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  45. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  46. package/src/openpress/reader/index.ts +10 -0
  47. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  48. package/src/openpress/reader/readerTypes.ts +4 -0
  49. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  50. package/src/openpress/reader/usePanelState.ts +56 -0
  51. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  52. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  53. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  54. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  55. package/src/openpress/shared/Panel.tsx +77 -0
  56. package/src/openpress/shared/index.ts +4 -0
  57. package/src/openpress/shared/numberUtils.ts +3 -0
  58. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  59. package/src/openpress/workbench/Workbench.tsx +407 -0
  60. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  61. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  62. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  63. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  64. package/src/openpress/workbench/actions/index.ts +5 -0
  65. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  66. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  67. package/src/openpress/workbench/dialog/index.ts +1 -0
  68. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  69. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  70. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  71. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  72. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  73. package/src/openpress/workbench/document/index.ts +10 -0
  74. package/src/openpress/workbench/index.ts +2 -0
  75. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  76. package/src/openpress/workbench/inspector/index.ts +5 -0
  77. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  78. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  79. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  80. package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
  81. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  82. package/src/openpress/workbench/mentions/index.ts +2 -0
  83. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  84. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  85. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
  86. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  87. package/src/openpress/workbench/panels/index.ts +3 -0
  88. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
  89. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  90. package/src/openpress/workbench/project/index.ts +2 -0
  91. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  92. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  93. package/src/openpress/workbench/shell/index.ts +1 -0
  94. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  95. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  96. package/src/styles/openpress/print-route.css +0 -2
  97. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  98. package/src/styles/openpress/public-viewer.css +25 -320
  99. package/src/styles/openpress/reader-runtime.css +243 -55
  100. package/src/styles/openpress/responsive.css +145 -270
  101. package/src/styles/openpress/workbench-panels.css +214 -178
  102. package/src/styles/openpress/workbench.css +986 -451
  103. package/src/styles/openpress.css +1 -1
  104. package/vite.config.ts +50 -0
  105. package/src/openpress/inspector.ts +0 -282
  106. package/src/openpress/projectWorkspace.tsx +0 -919
  107. package/src/openpress/readerRuntime.ts +0 -230
  108. package/src/openpress/workbench.tsx +0 -1265
  109. package/src/openpress/workbenchTypes.ts +0 -4
  110. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  111. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  112. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  113. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  114. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  115. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -0,0 +1,459 @@
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type FormEvent,
10
+ type RefObject,
11
+ } from "react";
12
+ import { ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
13
+ import { MentionSuggestionList, useComposerMentions } from "../mentions";
14
+ import type {
15
+ InspectorState,
16
+ ObjectSelection,
17
+ } from "./inspectorModel";
18
+ import type { ProjectMentionItem } from "../project";
19
+ import {
20
+ collectInspectorBlockElements,
21
+ createInspectorComposerStyle,
22
+ createInspectorInsertTargets,
23
+ createInspectorMarkerStyle,
24
+ rectToFixedStyle,
25
+ resolveInspectorSelectionRect,
26
+ syncInspectorSelectedBlock,
27
+ } from "./inspectorGeometryModel";
28
+ import {
29
+ getInlineSavedCommentForTarget,
30
+ getInlineSavedCommentMarkers,
31
+ } from "./inlineCommentModel";
32
+ import type {
33
+ InlineSavedComment,
34
+ InlineSavedCommentMarkerEntry,
35
+ InspectorCommentStatus,
36
+ InspectorInsertTargetView,
37
+ InspectorLayerRect,
38
+ } from "../workbenchTypes";
39
+
40
+ type ComposerAction = "add" | "edit" | "delete";
41
+
42
+ const COMPOSER_ACTIONS: Array<{ action: ComposerAction; label: string; icon: typeof Plus; prefix: string }> = [
43
+ { action: "add", label: "Add", icon: Plus, prefix: "請新增:" },
44
+ { action: "edit", label: "Edit", icon: Pencil, prefix: "請修改:" },
45
+ { action: "delete", label: "Remove", icon: Trash2, prefix: "請刪除這個物件。" },
46
+ ];
47
+
48
+ export interface InlineCommentController {
49
+ saved: InlineSavedComment[];
50
+ active: InlineSavedComment | null;
51
+ status: InspectorCommentStatus;
52
+ statusMessage: string;
53
+ totalCount?: number;
54
+ onOpenSaved: (comment: InlineSavedComment) => void;
55
+ onRemoveSaved: (comment: InlineSavedComment) => Promise<void>;
56
+ }
57
+
58
+ export interface InlineComposerController {
59
+ text: string;
60
+ submitDisabled: boolean;
61
+ mentionItems: ProjectMentionItem[];
62
+ onTextChange: (value: string) => void;
63
+ onSubmit: (event?: FormEvent<HTMLFormElement>) => Promise<void>;
64
+ }
65
+
66
+ export interface InlineInspectorLayerProps {
67
+ sourceContainerRef: RefObject<HTMLDivElement | null>;
68
+ inspector: InspectorState;
69
+ comments: InlineCommentController;
70
+ composer: InlineComposerController;
71
+ geometryVersion?: unknown;
72
+ }
73
+
74
+ function InlineInspectorLayerImpl({
75
+ sourceContainerRef,
76
+ inspector,
77
+ comments,
78
+ composer,
79
+ geometryVersion,
80
+ }: InlineInspectorLayerProps) {
81
+ const savedComments = comments.saved;
82
+ const savedCommentTotalCount = comments.totalCount ?? savedComments.length;
83
+ const activeSavedComment = comments.active;
84
+ const commentText = composer.text;
85
+ const commentStatus = comments.status;
86
+ const commentStatusMessage = comments.statusMessage;
87
+ const submitDisabled = composer.submitDisabled;
88
+ const mentionItems = composer.mentionItems;
89
+ const onOpenSavedComment = comments.onOpenSaved;
90
+ const onRemoveSavedComment = comments.onRemoveSaved;
91
+ const onCommentTextChange = composer.onTextChange;
92
+ const onSubmitComment = composer.onSubmit;
93
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
94
+ const composerRef = useRef<HTMLFormElement | null>(null);
95
+ const rafRef = useRef<number | null>(null);
96
+ const active = inspector.enabled && inspector.inspectorMode;
97
+ const selectedTarget = inspector.selectedTarget;
98
+ const selectedTargetKey = objectSelectionKey(selectedTarget);
99
+ const activeSavedCommentForTarget = selectedTarget && activeSavedComment
100
+ && inlineCommentTargetKey(activeSavedComment) === selectedTargetKey
101
+ && activeSavedComment.placement === selectedTarget.placement
102
+ ? activeSavedComment
103
+ : null;
104
+ const savedCommentForTarget = activeSavedCommentForTarget
105
+ ?? getInlineSavedCommentForTarget(savedComments, selectedTarget);
106
+ const markerEntries = useMemo<InlineSavedCommentMarkerEntry[]>(
107
+ () => getInlineSavedCommentMarkers(savedComments),
108
+ [savedComments],
109
+ );
110
+ const savedCommentLabels = useMemo(() => {
111
+ const labels = new Map<string, string>();
112
+ savedComments.forEach((comment, index) => labels.set(comment.id, String(index + 1)));
113
+ return labels;
114
+ }, [savedComments]);
115
+ const markerEntriesByTarget = useMemo(
116
+ () => new Set(markerEntries.map(({ target }) => objectSelectionKey(target))),
117
+ [markerEntries],
118
+ );
119
+ const markerDisplayEntries = useMemo(
120
+ () => [
121
+ ...markerEntries,
122
+ ...(selectedTarget && !markerEntriesByTarget.has(selectedTargetKey ?? "")
123
+ ? [{ target: selectedTarget, comments: [] }]
124
+ : []),
125
+ ],
126
+ [markerEntries, markerEntriesByTarget, selectedTarget, selectedTargetKey],
127
+ );
128
+ const [insertTargets, setInsertTargets] = useState<InspectorInsertTargetView[]>([]);
129
+ const [selectionRect, setSelectionRect] = useState<InspectorLayerRect | null>(null);
130
+ const [composerTargetKey, setComposerTargetKey] = useState<string | null>(null);
131
+ const composerOpen = Boolean(selectedTargetKey && composerTargetKey === selectedTargetKey);
132
+ const markerOnly = Boolean(savedCommentForTarget && !composerOpen && !activeSavedCommentForTarget);
133
+ const {
134
+ activeMention,
135
+ handleMentionKeyDown,
136
+ highlightedMentionIndex,
137
+ mentionSuggestions,
138
+ setHighlightedMentionIndex,
139
+ setComposerCursor,
140
+ syncCursor,
141
+ insertMention,
142
+ } = useComposerMentions({
143
+ text: commentText,
144
+ items: mentionItems,
145
+ textareaRef,
146
+ onTextChange: onCommentTextChange,
147
+ enabled: composerOpen,
148
+ });
149
+
150
+ const updateLayer = useCallback(() => {
151
+ const root = sourceContainerRef.current;
152
+ if (!active || !root) {
153
+ setInsertTargets([]);
154
+ setSelectionRect(null);
155
+ if (root) syncInspectorSelectedBlock(root, null);
156
+ return;
157
+ }
158
+
159
+ const blockElements = collectInspectorBlockElements(root);
160
+ const nextInsertTargets = createInspectorInsertTargets(blockElements);
161
+ setInsertTargets(nextInsertTargets);
162
+ setSelectionRect(resolveInspectorSelectionRect(root, selectedTarget, nextInsertTargets));
163
+ syncInspectorSelectedBlock(root, markerOnly ? null : selectedTarget);
164
+ }, [active, geometryVersion, markerOnly, selectedTarget, sourceContainerRef]);
165
+
166
+ const scheduleLayerUpdate = useCallback(() => {
167
+ if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
168
+ rafRef.current = window.requestAnimationFrame(() => {
169
+ rafRef.current = null;
170
+ updateLayer();
171
+ });
172
+ }, [updateLayer]);
173
+
174
+ useLayoutEffect(() => {
175
+ updateLayer();
176
+ }, [updateLayer]);
177
+
178
+ useEffect(() => {
179
+ if (!active) return undefined;
180
+ const root = sourceContainerRef.current;
181
+ const resizeObserver = typeof ResizeObserver === "undefined" || !root
182
+ ? null
183
+ : new ResizeObserver(scheduleLayerUpdate);
184
+ if (root && resizeObserver) resizeObserver.observe(root);
185
+ window.addEventListener("resize", scheduleLayerUpdate);
186
+ window.addEventListener("scroll", scheduleLayerUpdate, true);
187
+
188
+ return () => {
189
+ resizeObserver?.disconnect();
190
+ window.removeEventListener("resize", scheduleLayerUpdate);
191
+ window.removeEventListener("scroll", scheduleLayerUpdate, true);
192
+ if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
193
+ rafRef.current = null;
194
+ };
195
+ }, [active, scheduleLayerUpdate, sourceContainerRef]);
196
+
197
+ useEffect(() => {
198
+ if (!selectedTarget || composerTargetKey !== selectedTargetKey) return undefined;
199
+ let innerFrame: number | null = null;
200
+ const outerFrame = window.requestAnimationFrame(() => {
201
+ innerFrame = window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }));
202
+ });
203
+ return () => {
204
+ window.cancelAnimationFrame(outerFrame);
205
+ if (innerFrame !== null) window.cancelAnimationFrame(innerFrame);
206
+ };
207
+ }, [composerTargetKey, selectedTarget, selectedTargetKey]);
208
+
209
+ useEffect(() => {
210
+ setComposerTargetKey(null);
211
+ }, [selectedTargetKey]);
212
+
213
+ useEffect(() => {
214
+ if (commentStatus === "saved") setComposerTargetKey(null);
215
+ }, [commentStatus]);
216
+
217
+ useEffect(() => {
218
+ if (!composerOpen) return undefined;
219
+
220
+ const isInsideComposer = (target: EventTarget | null) => {
221
+ const composerElement = composerRef.current;
222
+ return Boolean(composerElement && target instanceof Node && composerElement.contains(target));
223
+ };
224
+ const blockOutsideComposer = (event: Event) => {
225
+ if (isInsideComposer(event.target)) return;
226
+ event.preventDefault();
227
+ event.stopPropagation();
228
+ };
229
+ const blockScrollKeyOutsideComposer = (event: KeyboardEvent) => {
230
+ if (!isScrollKey(event) || isInsideComposer(event.target)) return;
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ };
234
+
235
+ window.addEventListener("wheel", blockOutsideComposer, { capture: true, passive: false });
236
+ window.addEventListener("touchmove", blockOutsideComposer, { capture: true, passive: false });
237
+ window.addEventListener("keydown", blockScrollKeyOutsideComposer, { capture: true });
238
+ return () => {
239
+ window.removeEventListener("wheel", blockOutsideComposer, true);
240
+ window.removeEventListener("touchmove", blockOutsideComposer, true);
241
+ window.removeEventListener("keydown", blockScrollKeyOutsideComposer, true);
242
+ };
243
+ }, [composerOpen]);
244
+
245
+ if (!active) return null;
246
+
247
+ const composerStyle = selectionRect ? createInspectorComposerStyle(selectionRect, composerOpen) : undefined;
248
+ const visibleActionItems = savedCommentForTarget
249
+ ? COMPOSER_ACTIONS.filter((item) => item.action !== "add")
250
+ : COMPOSER_ACTIONS;
251
+ const applyComposerAction = (action: ComposerAction) => {
252
+ if (!selectedTargetKey) return;
253
+ const item = COMPOSER_ACTIONS.find((entry) => entry.action === action);
254
+ if (!item) return;
255
+ setComposerTargetKey(selectedTargetKey);
256
+ if (!commentText.trim()) onCommentTextChange(item.prefix);
257
+ };
258
+ const handleMarkerClick = (target: ObjectSelection, comments: InlineSavedComment[]) => {
259
+ if (!target) return;
260
+ inspector.selectSelection(target);
261
+ setComposerTargetKey(null);
262
+ if (comments.length === 0) {
263
+ onCommentTextChange("");
264
+ return;
265
+ }
266
+ onOpenSavedComment(comments[0]!);
267
+ };
268
+ const getMarkerRect = (target: ObjectSelection) => {
269
+ const root = sourceContainerRef.current;
270
+ if (!root) return null;
271
+ return resolveInspectorSelectionRect(root, target, insertTargets);
272
+ };
273
+ const markerViews = markerDisplayEntries.flatMap((markerEntry) => {
274
+ const markerRect = getMarkerRect(markerEntry.target);
275
+ if (!markerRect) return [];
276
+ if (!isMarkerRectNearViewport(markerRect)) return [];
277
+ return [{
278
+ markerEntry,
279
+ markerRect,
280
+ markerLabel: markerLabelForEntry(markerEntry, savedCommentLabels, savedCommentTotalCount),
281
+ markerStyle: createInspectorMarkerStyle(markerRect),
282
+ }];
283
+ }).sort((left, right) => compareMarkerRects(left.markerRect, right.markerRect));
284
+
285
+ return (
286
+ <div
287
+ className="openpress-inline-inspector-layer"
288
+ data-openpress-inline-inspector-layer
289
+ data-openpress-composer-lock-events={composerOpen ? "true" : "false"}
290
+ >
291
+ {insertTargets.map((target) => {
292
+ const isSelected = selectedTarget?.blockId === target.blockId && selectedTarget.placement === "before";
293
+ return (
294
+ <button
295
+ type="button"
296
+ className={`openpress-inline-insert-target${isSelected ? " is-selected" : ""}`}
297
+ data-openpress-insert-before-block-id={target.blockId}
298
+ style={rectToFixedStyle(target.rect)}
299
+ aria-label="在此新增註解"
300
+ key={target.blockId}
301
+ onClick={() => inspector.selectSelection({ blockId: target.blockId, placement: "before" })}
302
+ />
303
+ );
304
+ })}
305
+
306
+ {markerViews.map(({ markerEntry, markerLabel, markerStyle }) => {
307
+ const markerCount = markerEntry.comments.length;
308
+ const hasSavedComment = markerEntry.comments.length > 0;
309
+ return (
310
+ <button
311
+ type="button"
312
+ className="openpress-inline-comment-marker"
313
+ data-openpress-inline-comment-marker
314
+ data-openpress-inline-comment-marker-object-id={markerEntry.target.objectId}
315
+ data-openpress-inline-comment-marker-block-id={markerEntry.target.blockId}
316
+ data-openpress-inline-comment-marker-placement={markerEntry.target.placement}
317
+ data-openpress-marker-label={markerLabel}
318
+ data-openpress-marker-state={hasSavedComment ? "saved" : "draft"}
319
+ style={markerStyle}
320
+ aria-label={hasSavedComment ? `編輯註解 ${markerLabel},${markerCount} 則` : `目前選取區塊 ${markerLabel}`}
321
+ key={objectSelectionKey(markerEntry.target) ?? markerEntry.target.placement}
322
+ onClick={() => handleMarkerClick(markerEntry.target, markerEntry.comments)}
323
+ >
324
+ <span className="openpress-inline-comment-marker__index">{markerLabel}</span>
325
+ </button>
326
+ );
327
+ })}
328
+
329
+ {selectionRect && selectedTarget && !markerOnly ? (
330
+ <form
331
+ ref={composerRef}
332
+ className="openpress-inline-comment-composer"
333
+ data-openpress-inline-comment-composer
334
+ data-openpress-comment-placement={selectedTarget.placement}
335
+ data-openpress-composer-open={composerOpen ? "true" : "false"}
336
+ data-openpress-composer-saved={savedCommentForTarget ? "true" : "false"}
337
+ style={composerStyle}
338
+ onSubmit={(event) => void onSubmitComment(event)}
339
+ >
340
+ {!composerOpen ? (
341
+ <div className="openpress-inline-comment-composer__intents" aria-label="註解意圖">
342
+ {visibleActionItems.map((item) => {
343
+ const Icon = item.icon;
344
+ return (
345
+ <button
346
+ type="button"
347
+ aria-label={item.label}
348
+ title={item.label}
349
+ key={item.action}
350
+ onClick={() => {
351
+ if (savedCommentForTarget && item.action === "delete") {
352
+ void onRemoveSavedComment(savedCommentForTarget);
353
+ return;
354
+ }
355
+ applyComposerAction(item.action);
356
+ }}
357
+ >
358
+ <Icon aria-hidden="true" />
359
+ </button>
360
+ );
361
+ })}
362
+ </div>
363
+ ) : null}
364
+ {composerOpen ? (
365
+ <div className="openpress-inline-comment-composer__body">
366
+ <textarea
367
+ ref={textareaRef}
368
+ value={commentText}
369
+ disabled={commentStatus === "submitting"}
370
+ onChange={(event) => {
371
+ onCommentTextChange(event.target.value);
372
+ setComposerCursor(event.target.selectionStart ?? event.target.value.length);
373
+ }}
374
+ onClick={syncCursor}
375
+ onKeyUp={syncCursor}
376
+ onKeyDown={(event) => {
377
+ if (handleMentionKeyDown(event)) return;
378
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
379
+ event.preventDefault();
380
+ void onSubmitComment();
381
+ }
382
+ }}
383
+ aria-label={savedCommentForTarget ? "編輯註解" : "新增註解"}
384
+ placeholder="新增註解..."
385
+ rows={3}
386
+ />
387
+ <button type="submit" disabled={submitDisabled} aria-label="送出註解">
388
+ <ArrowUp aria-hidden="true" />
389
+ </button>
390
+ </div>
391
+ ) : null}
392
+ {composerOpen ? (
393
+ <MentionSuggestionList
394
+ className="openpress-inline-comment-composer__suggestions"
395
+ suggestions={mentionSuggestions}
396
+ highlightedIndex={highlightedMentionIndex}
397
+ ariaLabel={activeMention?.trigger === "/" ? "Skill suggestions" : "Mention suggestions"}
398
+ onHighlight={setHighlightedMentionIndex}
399
+ onSelect={insertMention}
400
+ />
401
+ ) : null}
402
+ {composerOpen && commentStatusMessage ? (
403
+ <p role="status" aria-live="polite" data-openpress-inspector-comment-status={commentStatus}>
404
+ {commentStatusMessage}
405
+ </p>
406
+ ) : null}
407
+ </form>
408
+ ) : null}
409
+ </div>
410
+ );
411
+ }
412
+
413
+ function objectSelectionKey(target: ObjectSelection | null) {
414
+ if (!target) return null;
415
+ return `${target.objectId ?? target.blockId ?? "unknown"}:${target.placement}`;
416
+ }
417
+
418
+ function inlineCommentTargetKey(comment: InlineSavedComment) {
419
+ return `${comment.objectId ?? comment.blockId ?? "unknown"}:${comment.placement}`;
420
+ }
421
+
422
+ function isScrollKey(event: KeyboardEvent) {
423
+ return event.key === " "
424
+ || event.key === "Spacebar"
425
+ || event.key === "PageDown"
426
+ || event.key === "PageUp"
427
+ || event.key === "Home"
428
+ || event.key === "End"
429
+ || event.key === "ArrowDown"
430
+ || event.key === "ArrowUp";
431
+ }
432
+
433
+ function compareMarkerRects(left: InspectorLayerRect, right: InspectorLayerRect) {
434
+ const topDelta = left.top - right.top;
435
+ if (Math.abs(topDelta) > 1) return topDelta;
436
+ return left.left - right.left;
437
+ }
438
+
439
+ function markerLabelForEntry(
440
+ markerEntry: InlineSavedCommentMarkerEntry,
441
+ savedCommentLabels: Map<string, string>,
442
+ savedCommentCount: number,
443
+ ) {
444
+ const firstComment = markerEntry.comments[0];
445
+ if (!firstComment) return String(savedCommentCount + 1);
446
+ if (firstComment.markerLabel) return firstComment.markerLabel;
447
+ return savedCommentLabels.get(firstComment.id) ?? String(savedCommentCount + 1);
448
+ }
449
+
450
+ function isMarkerRectNearViewport(rect: InspectorLayerRect, margin = 48) {
451
+ if (typeof window === "undefined") return true;
452
+ return rect.top + rect.height >= -margin
453
+ && rect.top <= window.innerHeight + margin
454
+ && rect.left + rect.width >= -margin
455
+ && rect.left <= window.innerWidth + margin;
456
+ }
457
+
458
+ export const InlineInspectorLayer = memo(InlineInspectorLayerImpl);
459
+ InlineInspectorLayer.displayName = "InlineInspectorLayer";
@@ -0,0 +1,5 @@
1
+ export * from "./InlineInspectorLayer";
2
+ export * from "./inlineCommentModel";
3
+ export * from "./inspectorGeometryModel";
4
+ export * from "./inspectorModel";
5
+ export * from "./useInspectorComments";
@@ -0,0 +1,125 @@
1
+ import type { ObjectSelection, PendingComment } from "./inspectorModel";
2
+ import type { SourceBlock } from "../../document-model";
3
+ import { parseCommentHint } from "../workbenchFormatters";
4
+ import type { InlineSavedComment, InlineSavedCommentMarkerEntry } from "../workbenchTypes";
5
+
6
+ export function getInlineSavedCommentForTarget(
7
+ comments: InlineSavedComment[],
8
+ target: ObjectSelection | null,
9
+ preferredId?: string | null,
10
+ ) {
11
+ if (!target) return null;
12
+ const targetKey = objectSelectionKey(target);
13
+ const targetComments = comments.filter((comment) => inlineCommentTargetKey(comment) === targetKey);
14
+ if (!targetComments.length) return null;
15
+ if (preferredId) {
16
+ const preferred = targetComments.find((comment) => comment.id === preferredId);
17
+ if (preferred) return preferred;
18
+ }
19
+ return targetComments[0] ?? null;
20
+ }
21
+
22
+ export function getInlineSavedCommentMarkers(comments: InlineSavedComment[]) {
23
+ const markerMap = new Map<string, InlineSavedCommentMarkerEntry>();
24
+
25
+ for (const comment of comments) {
26
+ const target: ObjectSelection = {
27
+ objectId: comment.objectId,
28
+ blockId: comment.blockId,
29
+ placement: comment.placement,
30
+ };
31
+ const key = objectSelectionKey(target);
32
+ if (!key) continue;
33
+ const bucket = markerMap.get(key);
34
+ if (bucket) {
35
+ bucket.comments.push(comment);
36
+ } else {
37
+ markerMap.set(key, { target, comments: [comment] });
38
+ }
39
+ }
40
+
41
+ return Array.from(markerMap.values()).map((entry) => ({
42
+ ...entry,
43
+ comments: [...entry.comments],
44
+ })).sort((left, right) => {
45
+ const leftId = left.target.objectId ?? left.target.blockId ?? "";
46
+ const rightId = right.target.objectId ?? right.target.blockId ?? "";
47
+ if (leftId === rightId) {
48
+ if (left.target.placement === right.target.placement) return 0;
49
+ return left.target.placement === "before" ? -1 : 1;
50
+ }
51
+ return leftId.localeCompare(rightId, "zh-Hant");
52
+ });
53
+ }
54
+
55
+ export function resolveInlineSavedComment(comment: PendingComment, sourceBlocksByPath: Record<string, SourceBlock[]>) {
56
+ const target = resolveInlineSavedCommentTarget(comment, sourceBlocksByPath);
57
+ if (!target) return [];
58
+ const hintMeta = parseCommentHint(comment.hint);
59
+ return [{
60
+ id: comment.id,
61
+ objectId: hintMeta?.targetObjectId,
62
+ blockId: target.id,
63
+ placement: hintMeta?.placement ?? "block",
64
+ note: comment.note,
65
+ path: comment.path,
66
+ line: comment.line,
67
+ timestamp: comment.timestamp,
68
+ }];
69
+ }
70
+
71
+ function objectSelectionKey(target: ObjectSelection | null) {
72
+ if (!target) return null;
73
+ return `${target.objectId ?? target.blockId ?? "unknown"}\u0000${target.placement}`;
74
+ }
75
+
76
+ function inlineCommentTargetKey(comment: InlineSavedComment) {
77
+ return `${comment.objectId ?? comment.blockId ?? "unknown"}\u0000${comment.placement}`;
78
+ }
79
+
80
+ export function groupSourceBlocksByPath(sourceBlockMap: Record<string, SourceBlock>) {
81
+ const grouped = Object.values(sourceBlockMap).reduce<Record<string, SourceBlock[]>>((accumulator, sourceBlock) => {
82
+ const path = normalizeSourcePath(sourceBlock.path);
83
+ if (!path) return accumulator;
84
+ if (!accumulator[path]) accumulator[path] = [];
85
+ accumulator[path].push(sourceBlock);
86
+ return accumulator;
87
+ }, {});
88
+
89
+ Object.values(grouped).forEach((blocks) => {
90
+ blocks.sort((left, right) => {
91
+ const leftLine = left.source?.line ?? Number.POSITIVE_INFINITY;
92
+ const rightLine = right.source?.line ?? Number.POSITIVE_INFINITY;
93
+ if (leftLine === rightLine) return left.id.localeCompare(right.id, "zh-Hant");
94
+ return leftLine - rightLine;
95
+ });
96
+ });
97
+
98
+ return grouped;
99
+ }
100
+
101
+ function resolveInlineSavedCommentTarget(comment: PendingComment, sourceBlocksByPath: Record<string, SourceBlock[]>) {
102
+ if (!comment.path || !comment.line) return null;
103
+ const commentPath = normalizeSourcePath(comment.path);
104
+ const candidateBlocks = sourceBlocksByPath[commentPath];
105
+ if (!candidateBlocks?.length) return null;
106
+
107
+ const normalizedLine = Number(comment.line);
108
+ if (!Number.isInteger(normalizedLine) || normalizedLine < 1) return null;
109
+
110
+ let selectedBlock: SourceBlock | null = null;
111
+ for (const block of candidateBlocks) {
112
+ if (typeof block.source?.line !== "number") continue;
113
+ if (block.source.line <= normalizedLine) {
114
+ selectedBlock = block;
115
+ continue;
116
+ }
117
+ break;
118
+ }
119
+ if (selectedBlock) return selectedBlock;
120
+ return candidateBlocks[0] ?? null;
121
+ }
122
+
123
+ function normalizeSourcePath(value: string) {
124
+ return value.trim().replaceAll("\\", "/").replace(/^\.\//, "").replace(/^document\//, "");
125
+ }