@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.
- package/engine/commands/dev.mjs +2 -2
- package/engine/commands/upgrade.mjs +47 -5
- package/engine/output/chrome-pdf.mjs +18 -3
- package/engine/output/static-server.mjs +39 -0
- package/engine/react/comment-endpoint.mjs +13 -39
- package/engine/react/comment-marker.mjs +30 -6
- package/engine/react/document-entry.mjs +11 -0
- package/engine/react/document-export.mjs +45 -5
- package/engine/react/http-json.mjs +24 -0
- package/engine/react/mdx-compile.mjs +187 -3
- package/engine/react/measurement-css.mjs +93 -1
- package/engine/react/object-entities.mjs +119 -0
- package/engine/react/pipeline/allocate.mjs +10 -7
- package/engine/react/pipeline/frame-measurement.mjs +40 -9
- package/engine/react/project-asset-endpoint.mjs +6 -24
- package/engine/react/source-edit-endpoint.d.mts +10 -0
- package/engine/react/source-edit-endpoint.mjs +75 -0
- package/engine/react/sources/mdx-resolver.mjs +12 -14
- package/engine/react/style-discovery.mjs +1 -4
- package/engine/runtime/file-walk.mjs +22 -0
- package/engine/runtime/inspection.mjs +1 -20
- package/engine/runtime/path-utils.mjs +20 -0
- package/engine/runtime/source-text-tools.d.mts +102 -0
- package/engine/runtime/source-text-tools.mjs +551 -16
- package/engine/runtime/source-workspace.mjs +4 -31
- package/package.json +1 -1
- package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
- package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
- package/src/openpress/app/index.ts +2 -0
- package/src/openpress/core/Frame.tsx +9 -11
- package/src/openpress/core/FrameContext.tsx +8 -3
- package/src/openpress/core/MdxArea.tsx +11 -12
- package/src/openpress/core/cn.ts +4 -0
- package/src/openpress/core/index.tsx +2 -1
- package/src/openpress/core/primitives.tsx +29 -8
- package/src/openpress/core/types.ts +8 -0
- package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
- package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
- package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
- package/src/openpress/document-model/index.ts +6 -0
- package/src/openpress/document-model/objectEntityModel.ts +51 -0
- package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
- package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
- package/src/openpress/manuscript/index.tsx +49 -7
- package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
- package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
- package/src/openpress/reader/index.ts +10 -0
- package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
- package/src/openpress/reader/readerTypes.ts +4 -0
- package/src/openpress/reader/usePageViewportScale.ts +119 -0
- package/src/openpress/reader/usePanelState.ts +56 -0
- package/src/openpress/reader/useReaderHashSync.ts +61 -0
- package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
- package/src/openpress/reader/useReaderRuntime.ts +146 -0
- package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
- package/src/openpress/shared/Panel.tsx +77 -0
- package/src/openpress/shared/index.ts +4 -0
- package/src/openpress/shared/numberUtils.ts +3 -0
- package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
- package/src/openpress/workbench/Workbench.tsx +407 -0
- package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
- package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
- package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
- package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
- package/src/openpress/workbench/actions/index.ts +5 -0
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
- package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
- package/src/openpress/workbench/dialog/index.ts +1 -0
- package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
- package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
- package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
- package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
- package/src/openpress/workbench/document/index.ts +10 -0
- package/src/openpress/workbench/index.ts +2 -0
- package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
- package/src/openpress/workbench/inspector/index.ts +5 -0
- package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
- package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
- package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
- package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
- package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
- package/src/openpress/workbench/mentions/index.ts +2 -0
- package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
- package/src/openpress/workbench/panels/Panel.tsx +1 -0
- package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
- package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
- package/src/openpress/workbench/panels/index.ts +3 -0
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
- package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
- package/src/openpress/workbench/project/index.ts +2 -0
- package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
- package/src/openpress/workbench/shell/index.ts +1 -0
- package/src/openpress/workbench/workbenchFormatters.ts +120 -0
- package/src/openpress/workbench/workbenchTypes.ts +35 -0
- package/src/styles/openpress/print-route.css +0 -2
- package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
- package/src/styles/openpress/public-viewer.css +25 -320
- package/src/styles/openpress/reader-runtime.css +243 -55
- package/src/styles/openpress/responsive.css +145 -270
- package/src/styles/openpress/workbench-panels.css +214 -178
- package/src/styles/openpress/workbench.css +986 -451
- package/src/styles/openpress.css +1 -1
- package/vite.config.ts +50 -0
- package/src/openpress/inspector.ts +0 -282
- package/src/openpress/projectWorkspace.tsx +0 -919
- package/src/openpress/readerRuntime.ts +0 -230
- package/src/openpress/workbench.tsx +0 -1265
- package/src/openpress/workbenchTypes.ts +0 -4
- /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
- /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
- /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
- /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
- /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
- /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
|
@@ -0,0 +1,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,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
|
+
}
|