@lemonriver/core 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +21 -15
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- package/src/app/components/inspector/comment-widget.tsx +0 -119
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { ArrowUp, Check, MessageSquare, Trash2 } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
type ComponentProps,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
type RefObject,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { toast } from 'sonner';
|
|
14
|
+
import { requestAgent, useAgentStatus } from '@/lib/agent/use-agent';
|
|
15
|
+
import { findSlideSource } from '@/lib/inspector/fiber';
|
|
16
|
+
import { useFreezeSlideMotion } from '@/lib/inspector/freeze-motion';
|
|
17
|
+
import { type Point, type Rect, rectContains } from '@/lib/inspector/geometry';
|
|
18
|
+
import {
|
|
19
|
+
isInspectableEventTarget,
|
|
20
|
+
pickElement,
|
|
21
|
+
pickInspectorTarget,
|
|
22
|
+
} from '@/lib/inspector/pick-target';
|
|
23
|
+
import type { CommentRect, SlideComment } from '@/lib/inspector/use-comments';
|
|
24
|
+
import { type Canvas, readCanvas, readFrame } from '@/lib/inspector/visual-dom';
|
|
25
|
+
import { IS_APPLE, isTypingTarget } from '@/lib/keys';
|
|
26
|
+
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
27
|
+
import { cn } from '@/lib/utils';
|
|
28
|
+
import { applyCommentsPrompt } from '../../../agent/prompt';
|
|
29
|
+
import { type SelectedTarget, useInspector } from './inspector-provider';
|
|
30
|
+
|
|
31
|
+
type ScreenRect = { left: number; top: number; width: number; height: number };
|
|
32
|
+
type Size = { width: number; height: number };
|
|
33
|
+
|
|
34
|
+
type Draft =
|
|
35
|
+
| { kind: 'element'; target: SelectedTarget }
|
|
36
|
+
| { kind: 'region'; rect: Rect; target: SelectedTarget };
|
|
37
|
+
|
|
38
|
+
type Gesture = {
|
|
39
|
+
pointerId: number;
|
|
40
|
+
start: Point;
|
|
41
|
+
hit: SelectedTarget | null;
|
|
42
|
+
canvas: Canvas;
|
|
43
|
+
moved: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type PinLayout = { frame: ScreenRect; region: boolean };
|
|
47
|
+
|
|
48
|
+
type Layout = {
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
canvas: ScreenRect | null;
|
|
52
|
+
scale: number;
|
|
53
|
+
pins: Record<string, PinLayout>;
|
|
54
|
+
hoverFrame: ScreenRect | null;
|
|
55
|
+
draftFrame: ScreenRect | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const EMPTY_LAYOUT: Layout = {
|
|
59
|
+
width: 0,
|
|
60
|
+
height: 0,
|
|
61
|
+
canvas: null,
|
|
62
|
+
scale: 1,
|
|
63
|
+
pins: {},
|
|
64
|
+
hoverFrame: null,
|
|
65
|
+
draftFrame: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// A freshly written marker shifts source lines, so a comment's `target`
|
|
69
|
+
// can point at a loc the canvas only picks up once HMR re-renders — and
|
|
70
|
+
// that reload can remount this layer. Remember each pin's last frame
|
|
71
|
+
// across mounts so it doesn't jump to the fallback corner in between.
|
|
72
|
+
const rememberedFrames = new Map<string, PinLayout>();
|
|
73
|
+
|
|
74
|
+
const PIN = 24;
|
|
75
|
+
const GAP = 8;
|
|
76
|
+
const MIN_REGION = 6;
|
|
77
|
+
|
|
78
|
+
function pinRect(frame: ScreenRect): ScreenRect {
|
|
79
|
+
return { left: frame.left + frame.width - 3, top: frame.top - PIN + 3, width: PIN, height: PIN };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function placeBeside(anchor: ScreenRect, size: Size, bounds: Size): { left: number; top: number } {
|
|
83
|
+
let left = anchor.left + anchor.width + GAP;
|
|
84
|
+
if (left + size.width > bounds.width - GAP) left = anchor.left - size.width - GAP;
|
|
85
|
+
if (left < GAP) left = Math.max(GAP, Math.min(anchor.left, bounds.width - size.width - GAP));
|
|
86
|
+
const top = Math.max(GAP, Math.min(anchor.top, bounds.height - size.height - GAP));
|
|
87
|
+
return { left, top };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fallbackFrame(canvas: Canvas): Rect {
|
|
91
|
+
return { x: canvas.width - 72, y: 24, width: 48, height: 48 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Deepest host element whose box fully contains the drawn region; falls
|
|
95
|
+
// back to the largest host element (usually the page wrapper) so the
|
|
96
|
+
// marker always has a JSX container to live in.
|
|
97
|
+
function containerFor(rect: Rect, canvas: Canvas, slideId: string): SelectedTarget | null {
|
|
98
|
+
const tolerance = {
|
|
99
|
+
x: rect.x - 1,
|
|
100
|
+
y: rect.y - 1,
|
|
101
|
+
width: rect.width + 2,
|
|
102
|
+
height: rect.height + 2,
|
|
103
|
+
};
|
|
104
|
+
let best: { target: SelectedTarget; area: number } | null = null;
|
|
105
|
+
let largest: { target: SelectedTarget; area: number } | null = null;
|
|
106
|
+
const seen = new Set<HTMLElement>();
|
|
107
|
+
for (const element of canvas.root.querySelectorAll<HTMLElement>('[data-slide-loc]')) {
|
|
108
|
+
const hit = findSlideSource(element, slideId, { hostOnly: true });
|
|
109
|
+
if (!hit || seen.has(hit.anchor)) continue;
|
|
110
|
+
seen.add(hit.anchor);
|
|
111
|
+
const frame = readFrame(hit.anchor, canvas);
|
|
112
|
+
const area = frame.width * frame.height;
|
|
113
|
+
if (!area) continue;
|
|
114
|
+
if (!largest || area > largest.area) largest = { target: hit, area };
|
|
115
|
+
if (rectContains(frame, tolerance) && (!best || area < best.area)) best = { target: hit, area };
|
|
116
|
+
}
|
|
117
|
+
return best?.target ?? largest?.target ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function CommentLayer() {
|
|
121
|
+
const { commentMode, exitCommentMode, slideId, pageIndex, comments, add, update, remove, error } =
|
|
122
|
+
useInspector();
|
|
123
|
+
const t = useLocale();
|
|
124
|
+
const agent = useAgentStatus();
|
|
125
|
+
useFreezeSlideMotion(commentMode ? 'comment' : null);
|
|
126
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
127
|
+
const gestureRef = useRef<Gesture | null>(null);
|
|
128
|
+
const [hover, setHover] = useState<HTMLElement | null>(null);
|
|
129
|
+
const [marquee, setMarquee] = useState<Rect | null>(null);
|
|
130
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
131
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
132
|
+
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
133
|
+
const [layout, setLayout] = useState<Layout>(EMPTY_LAYOUT);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (commentMode) return;
|
|
137
|
+
gestureRef.current = null;
|
|
138
|
+
setHover(null);
|
|
139
|
+
setMarquee(null);
|
|
140
|
+
setDraft(null);
|
|
141
|
+
setOpenId(null);
|
|
142
|
+
setHoverId(null);
|
|
143
|
+
}, [commentMode]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
void pageIndex;
|
|
147
|
+
setDraft(null);
|
|
148
|
+
setOpenId(null);
|
|
149
|
+
}, [pageIndex]);
|
|
150
|
+
|
|
151
|
+
useLayoutEffect(() => {
|
|
152
|
+
if (!commentMode) return;
|
|
153
|
+
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
154
|
+
let raf = 0;
|
|
155
|
+
const measure = () => {
|
|
156
|
+
const overlay = overlayRef.current;
|
|
157
|
+
const canvas = readCanvas();
|
|
158
|
+
if (!overlay || !canvas) return;
|
|
159
|
+
const origin = overlay.getBoundingClientRect();
|
|
160
|
+
const screen = (rect: Rect): ScreenRect => ({
|
|
161
|
+
left: canvas.rect.left - origin.left + rect.x * canvas.scale,
|
|
162
|
+
top: canvas.rect.top - origin.top + rect.y * canvas.scale,
|
|
163
|
+
width: rect.width * canvas.scale,
|
|
164
|
+
height: rect.height * canvas.scale,
|
|
165
|
+
});
|
|
166
|
+
const element = (anchor: HTMLElement): ScreenRect => {
|
|
167
|
+
const box = anchor.getBoundingClientRect();
|
|
168
|
+
return {
|
|
169
|
+
left: box.left - origin.left,
|
|
170
|
+
top: box.top - origin.top,
|
|
171
|
+
width: box.width,
|
|
172
|
+
height: box.height,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
const region = (rect: CommentRect): ScreenRect =>
|
|
176
|
+
screen({ x: rect.x, y: rect.y, width: rect.w, height: rect.h });
|
|
177
|
+
const pins: Record<string, PinLayout> = {};
|
|
178
|
+
for (const comment of comments) {
|
|
179
|
+
const memoryKey = `${slideId}:${comment.id}`;
|
|
180
|
+
// Pages often share a wrapper component, so the target loc alone
|
|
181
|
+
// would match on every page; the stored page index decides.
|
|
182
|
+
if (comment.page !== undefined && comment.page !== pageIndex) continue;
|
|
183
|
+
const anchor = comment.target
|
|
184
|
+
? canvas.root.querySelector<HTMLElement>(
|
|
185
|
+
`[data-slide-loc="${comment.target.line}:${comment.target.column}"]`,
|
|
186
|
+
)
|
|
187
|
+
: null;
|
|
188
|
+
if (anchor) {
|
|
189
|
+
pins[comment.id] = {
|
|
190
|
+
frame: comment.rect ? region(comment.rect) : element(anchor),
|
|
191
|
+
region: !!comment.rect,
|
|
192
|
+
};
|
|
193
|
+
rememberedFrames.set(memoryKey, pins[comment.id]);
|
|
194
|
+
} else if (comment.page === pageIndex) {
|
|
195
|
+
pins[comment.id] = comment.rect
|
|
196
|
+
? { frame: region(comment.rect), region: true }
|
|
197
|
+
: (rememberedFrames.get(memoryKey) ?? {
|
|
198
|
+
frame: screen(fallbackFrame(canvas)),
|
|
199
|
+
region: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const draftFrame = !draft
|
|
204
|
+
? null
|
|
205
|
+
: draft.kind === 'region'
|
|
206
|
+
? screen(draft.rect)
|
|
207
|
+
: draft.target.anchor.isConnected
|
|
208
|
+
? element(draft.target.anchor)
|
|
209
|
+
: null;
|
|
210
|
+
const next: Layout = {
|
|
211
|
+
width: origin.width,
|
|
212
|
+
height: origin.height,
|
|
213
|
+
canvas: screen({ x: 0, y: 0, width: canvas.width, height: canvas.height }),
|
|
214
|
+
scale: canvas.scale,
|
|
215
|
+
pins,
|
|
216
|
+
hoverFrame: hover?.isConnected ? element(hover) : null,
|
|
217
|
+
draftFrame,
|
|
218
|
+
};
|
|
219
|
+
setLayout((previous) =>
|
|
220
|
+
JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
const schedule = () => {
|
|
224
|
+
if (raf) return;
|
|
225
|
+
raf = requestAnimationFrame(() => {
|
|
226
|
+
raf = 0;
|
|
227
|
+
measure();
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
const stopAt = performance.now() + 420;
|
|
231
|
+
const track = () => {
|
|
232
|
+
measure();
|
|
233
|
+
if (performance.now() < stopAt) raf = requestAnimationFrame(track);
|
|
234
|
+
else raf = 0;
|
|
235
|
+
};
|
|
236
|
+
track();
|
|
237
|
+
const resize = new ResizeObserver(schedule);
|
|
238
|
+
if (overlayRef.current) resize.observe(overlayRef.current);
|
|
239
|
+
const mutations = root ? new MutationObserver(schedule) : null;
|
|
240
|
+
mutations?.observe(root as HTMLElement, {
|
|
241
|
+
childList: true,
|
|
242
|
+
subtree: true,
|
|
243
|
+
attributes: true,
|
|
244
|
+
attributeFilter: ['data-slide-loc', 'style', 'class'],
|
|
245
|
+
});
|
|
246
|
+
window.addEventListener('resize', schedule);
|
|
247
|
+
window.addEventListener('scroll', schedule, true);
|
|
248
|
+
return () => {
|
|
249
|
+
cancelAnimationFrame(raf);
|
|
250
|
+
resize.disconnect();
|
|
251
|
+
mutations?.disconnect();
|
|
252
|
+
window.removeEventListener('resize', schedule);
|
|
253
|
+
window.removeEventListener('scroll', schedule, true);
|
|
254
|
+
};
|
|
255
|
+
}, [commentMode, comments, pageIndex, hover, draft, slideId]);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!commentMode) return;
|
|
259
|
+
const targetAt = (event: PointerEvent): SelectedTarget | null => {
|
|
260
|
+
const element = pickInspectorTarget(pickElement(event.clientX, event.clientY));
|
|
261
|
+
return element ? findSlideSource(element, slideId, { hostOnly: true }) : null;
|
|
262
|
+
};
|
|
263
|
+
const point = (event: PointerEvent, canvas: Canvas): Point => ({
|
|
264
|
+
x: (event.clientX - canvas.rect.left) / canvas.scale,
|
|
265
|
+
y: (event.clientY - canvas.rect.top) / canvas.scale,
|
|
266
|
+
});
|
|
267
|
+
const onDown = (event: PointerEvent) => {
|
|
268
|
+
if (event.button !== 0 || !event.isPrimary || gestureRef.current) return;
|
|
269
|
+
if (!isInspectableEventTarget(event.target)) return;
|
|
270
|
+
const canvas = readCanvas();
|
|
271
|
+
if (!canvas) return;
|
|
272
|
+
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
|
273
|
+
setDraft(null);
|
|
274
|
+
setOpenId(null);
|
|
275
|
+
gestureRef.current = {
|
|
276
|
+
pointerId: event.pointerId,
|
|
277
|
+
start: point(event, canvas),
|
|
278
|
+
hit: targetAt(event),
|
|
279
|
+
canvas,
|
|
280
|
+
moved: false,
|
|
281
|
+
};
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
event.stopPropagation();
|
|
284
|
+
};
|
|
285
|
+
const onMove = (event: PointerEvent) => {
|
|
286
|
+
const gesture = gestureRef.current;
|
|
287
|
+
if (gesture) {
|
|
288
|
+
if (gesture.pointerId !== event.pointerId) return;
|
|
289
|
+
const current = point(event, gesture.canvas);
|
|
290
|
+
const delta = { x: current.x - gesture.start.x, y: current.y - gesture.start.y };
|
|
291
|
+
if (!gesture.moved && Math.hypot(delta.x, delta.y) * gesture.canvas.scale < 3) return;
|
|
292
|
+
gesture.moved = true;
|
|
293
|
+
setHover(null);
|
|
294
|
+
setMarquee({
|
|
295
|
+
x: Math.min(gesture.start.x, current.x),
|
|
296
|
+
y: Math.min(gesture.start.y, current.y),
|
|
297
|
+
width: Math.abs(delta.x),
|
|
298
|
+
height: Math.abs(delta.y),
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!isInspectableEventTarget(event.target)) {
|
|
303
|
+
setHover(null);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
setHover(targetAt(event)?.anchor ?? null);
|
|
307
|
+
};
|
|
308
|
+
const finish = (event: PointerEvent, cancelled: boolean) => {
|
|
309
|
+
const gesture = gestureRef.current;
|
|
310
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
311
|
+
gestureRef.current = null;
|
|
312
|
+
setMarquee(null);
|
|
313
|
+
if (cancelled) return;
|
|
314
|
+
if (gesture.moved) {
|
|
315
|
+
const current = point(event, gesture.canvas);
|
|
316
|
+
const rect = {
|
|
317
|
+
x: Math.min(gesture.start.x, current.x),
|
|
318
|
+
y: Math.min(gesture.start.y, current.y),
|
|
319
|
+
width: Math.abs(current.x - gesture.start.x),
|
|
320
|
+
height: Math.abs(current.y - gesture.start.y),
|
|
321
|
+
};
|
|
322
|
+
if (
|
|
323
|
+
rect.width * gesture.canvas.scale < MIN_REGION ||
|
|
324
|
+
rect.height * gesture.canvas.scale < MIN_REGION
|
|
325
|
+
)
|
|
326
|
+
return;
|
|
327
|
+
const target = containerFor(rect, gesture.canvas, slideId);
|
|
328
|
+
if (!target) {
|
|
329
|
+
toast.error(t.inspector.commentRegionNoTarget);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
setDraft({ kind: 'region', rect, target });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (gesture.hit) setDraft({ kind: 'element', target: gesture.hit });
|
|
336
|
+
};
|
|
337
|
+
const onUp = (event: PointerEvent) => finish(event, false);
|
|
338
|
+
const onCancel = (event: Event) => {
|
|
339
|
+
if (event instanceof PointerEvent) finish(event, true);
|
|
340
|
+
else {
|
|
341
|
+
gestureRef.current = null;
|
|
342
|
+
setMarquee(null);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const onClick = (event: MouseEvent) => {
|
|
346
|
+
if (!isInspectableEventTarget(event.target)) return;
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
event.stopImmediatePropagation();
|
|
349
|
+
};
|
|
350
|
+
const onKey = (event: KeyboardEvent) => {
|
|
351
|
+
if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return;
|
|
352
|
+
if (isTypingTarget(event.target) && !overlayRef.current?.contains(event.target as Node))
|
|
353
|
+
return;
|
|
354
|
+
if (
|
|
355
|
+
event.target instanceof Element &&
|
|
356
|
+
event.target.closest('[role="dialog"], [role="menu"], [role="listbox"]')
|
|
357
|
+
)
|
|
358
|
+
return;
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
event.stopImmediatePropagation();
|
|
361
|
+
if (gestureRef.current) {
|
|
362
|
+
gestureRef.current = null;
|
|
363
|
+
setMarquee(null);
|
|
364
|
+
} else if (draft) setDraft(null);
|
|
365
|
+
else if (openId) setOpenId(null);
|
|
366
|
+
else exitCommentMode();
|
|
367
|
+
};
|
|
368
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
369
|
+
window.addEventListener('pointermove', onMove, true);
|
|
370
|
+
window.addEventListener('pointerup', onUp, true);
|
|
371
|
+
window.addEventListener('pointercancel', onCancel, true);
|
|
372
|
+
window.addEventListener('blur', onCancel);
|
|
373
|
+
window.addEventListener('click', onClick, true);
|
|
374
|
+
window.addEventListener('keydown', onKey, true);
|
|
375
|
+
return () => {
|
|
376
|
+
window.removeEventListener('pointerdown', onDown, true);
|
|
377
|
+
window.removeEventListener('pointermove', onMove, true);
|
|
378
|
+
window.removeEventListener('pointerup', onUp, true);
|
|
379
|
+
window.removeEventListener('pointercancel', onCancel, true);
|
|
380
|
+
window.removeEventListener('blur', onCancel);
|
|
381
|
+
window.removeEventListener('click', onClick, true);
|
|
382
|
+
window.removeEventListener('keydown', onKey, true);
|
|
383
|
+
};
|
|
384
|
+
}, [commentMode, slideId, draft, openId, exitCommentMode, t]);
|
|
385
|
+
|
|
386
|
+
const submit = useCallback(
|
|
387
|
+
async (text: string) => {
|
|
388
|
+
if (!draft) return;
|
|
389
|
+
const { target } = draft;
|
|
390
|
+
const rect =
|
|
391
|
+
draft.kind === 'region'
|
|
392
|
+
? {
|
|
393
|
+
x: Math.round(draft.rect.x),
|
|
394
|
+
y: Math.round(draft.rect.y),
|
|
395
|
+
w: Math.round(draft.rect.width),
|
|
396
|
+
h: Math.round(draft.rect.height),
|
|
397
|
+
}
|
|
398
|
+
: undefined;
|
|
399
|
+
try {
|
|
400
|
+
await add({ line: target.line, column: target.column, text, rect, page: pageIndex });
|
|
401
|
+
setDraft(null);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
toast.error(`${t.inspector.commentAddFailed} ${String((err as Error).message ?? err)}`);
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
[draft, add, pageIndex, t],
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const handleUpdate = useCallback(
|
|
410
|
+
async (id: string, text: string) => {
|
|
411
|
+
try {
|
|
412
|
+
await update(id, text);
|
|
413
|
+
setOpenId(null);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
toast.error(`${t.inspector.commentUpdateFailed} ${String((err as Error).message ?? err)}`);
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
[update, t],
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const handleRemove = useCallback(
|
|
422
|
+
async (id: string) => {
|
|
423
|
+
try {
|
|
424
|
+
await remove(id);
|
|
425
|
+
setOpenId((current) => (current === id ? null : current));
|
|
426
|
+
} catch (err) {
|
|
427
|
+
toast.error(String((err as Error).message ?? err));
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
[remove],
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
if (!commentMode) return null;
|
|
434
|
+
|
|
435
|
+
const bounds = { width: layout.width, height: layout.height };
|
|
436
|
+
const toScreen = (rect: Rect): ScreenRect => ({
|
|
437
|
+
left: (layout.canvas?.left ?? 0) + rect.x * layout.scale,
|
|
438
|
+
top: (layout.canvas?.top ?? 0) + rect.y * layout.scale,
|
|
439
|
+
width: rect.width * layout.scale,
|
|
440
|
+
height: rect.height * layout.scale,
|
|
441
|
+
});
|
|
442
|
+
const count = comments.length;
|
|
443
|
+
|
|
444
|
+
return (
|
|
445
|
+
<div
|
|
446
|
+
ref={overlayRef}
|
|
447
|
+
data-inspector-ui
|
|
448
|
+
data-comment-layer
|
|
449
|
+
className="pointer-events-none absolute inset-0 z-30 select-none"
|
|
450
|
+
>
|
|
451
|
+
{layout.hoverFrame && !gestureRef.current && !draft && (
|
|
452
|
+
<div className="absolute border border-dashed border-brand/70" style={layout.hoverFrame} />
|
|
453
|
+
)}
|
|
454
|
+
{marquee && (
|
|
455
|
+
<div className="absolute border border-brand bg-brand/10" style={toScreen(marquee)} />
|
|
456
|
+
)}
|
|
457
|
+
{comments.map((comment) => {
|
|
458
|
+
const pin = layout.pins[comment.id];
|
|
459
|
+
if (!pin) return null;
|
|
460
|
+
const open = openId === comment.id;
|
|
461
|
+
return (
|
|
462
|
+
<CommentPin
|
|
463
|
+
key={comment.id}
|
|
464
|
+
comment={comment}
|
|
465
|
+
frame={pin.frame}
|
|
466
|
+
region={pin.region}
|
|
467
|
+
anchor={pinRect(pin.frame)}
|
|
468
|
+
bounds={bounds}
|
|
469
|
+
open={open}
|
|
470
|
+
hovered={hoverId === comment.id}
|
|
471
|
+
onToggle={() => {
|
|
472
|
+
setDraft(null);
|
|
473
|
+
setOpenId(open ? null : comment.id);
|
|
474
|
+
}}
|
|
475
|
+
onHover={(hovering) => setHoverId(hovering ? comment.id : null)}
|
|
476
|
+
onSave={(text) => handleUpdate(comment.id, text)}
|
|
477
|
+
onRemove={() => handleRemove(comment.id)}
|
|
478
|
+
/>
|
|
479
|
+
);
|
|
480
|
+
})}
|
|
481
|
+
{draft && layout.draftFrame && (
|
|
482
|
+
<Composer
|
|
483
|
+
frame={layout.draftFrame}
|
|
484
|
+
region={draft.kind === 'region'}
|
|
485
|
+
anchor={pinRect(layout.draftFrame)}
|
|
486
|
+
bounds={bounds}
|
|
487
|
+
onSubmit={submit}
|
|
488
|
+
/>
|
|
489
|
+
)}
|
|
490
|
+
<div className="absolute inset-x-0 bottom-3 flex justify-center">
|
|
491
|
+
<IslandHint
|
|
492
|
+
contentKey={
|
|
493
|
+
error ? 'error' : count === 0 ? 'empty' : agent.connected ? 'count-agent' : 'count'
|
|
494
|
+
}
|
|
495
|
+
tone={error ? 'error' : 'default'}
|
|
496
|
+
>
|
|
497
|
+
{error ? (
|
|
498
|
+
error
|
|
499
|
+
) : count === 0 ? (
|
|
500
|
+
t.inspector.commentEmptyHint
|
|
501
|
+
) : agent.connected ? (
|
|
502
|
+
<>
|
|
503
|
+
<CommentCountLabel count={count} />
|
|
504
|
+
<span aria-hidden className="mx-1.5">
|
|
505
|
+
·
|
|
506
|
+
</span>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
className="pointer-events-auto rounded-[4px] px-1 py-0.5 font-medium text-brand outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
510
|
+
onClick={() => requestAgent({ text: applyCommentsPrompt(slideId) })}
|
|
511
|
+
>
|
|
512
|
+
{format(t.agent.applyCommentsWith, { provider: agent.providerName ?? '' })}
|
|
513
|
+
</button>
|
|
514
|
+
</>
|
|
515
|
+
) : (
|
|
516
|
+
<>
|
|
517
|
+
<CommentCountLabel count={count} />
|
|
518
|
+
<span aria-hidden className="mx-1.5">
|
|
519
|
+
·
|
|
520
|
+
</span>
|
|
521
|
+
{t.inspector.commentsApplyHintPrefix}
|
|
522
|
+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
|
|
523
|
+
/apply-comments
|
|
524
|
+
</code>
|
|
525
|
+
{t.inspector.commentsApplyHintSuffix}
|
|
526
|
+
</>
|
|
527
|
+
)}
|
|
528
|
+
</IslandHint>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const ISLAND_SWAP_MS = 320;
|
|
535
|
+
const SUBMIT_SHORTCUT = IS_APPLE ? '⌘↵' : 'Ctrl ↵';
|
|
536
|
+
|
|
537
|
+
// Only the digits roll when the count changes; the surrounding words
|
|
538
|
+
// stay put so the pill reads as a counter ticking, not a text swap.
|
|
539
|
+
function CommentCountLabel({ count }: { count: number }) {
|
|
540
|
+
const t = useLocale();
|
|
541
|
+
const [prefix, suffix] = plural(count, t.inspector.commentsCount).split('{count}');
|
|
542
|
+
return (
|
|
543
|
+
<span className="font-medium text-foreground nums">
|
|
544
|
+
{prefix}
|
|
545
|
+
<RollingNumber value={count} />
|
|
546
|
+
{suffix}
|
|
547
|
+
</span>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function RollingNumber({ value }: { value: number }) {
|
|
552
|
+
const [outgoing, setOutgoing] = useState<number | null>(null);
|
|
553
|
+
const lastRef = useRef(value);
|
|
554
|
+
useLayoutEffect(() => {
|
|
555
|
+
const previous = lastRef.current;
|
|
556
|
+
if (previous === value) return;
|
|
557
|
+
lastRef.current = value;
|
|
558
|
+
setOutgoing(previous);
|
|
559
|
+
const timer = setTimeout(() => setOutgoing(null), ISLAND_SWAP_MS);
|
|
560
|
+
return () => clearTimeout(timer);
|
|
561
|
+
}, [value]);
|
|
562
|
+
const direction = outgoing !== null && value < outgoing ? -1 : 1;
|
|
563
|
+
return (
|
|
564
|
+
<span
|
|
565
|
+
className="inline-grid overflow-hidden align-bottom"
|
|
566
|
+
style={{ '--roll': direction } as CSSProperties}
|
|
567
|
+
>
|
|
568
|
+
{outgoing !== null && (
|
|
569
|
+
<span key={`out-${outgoing}`} aria-hidden className="roll-leave col-start-1 row-start-1">
|
|
570
|
+
{outgoing}
|
|
571
|
+
</span>
|
|
572
|
+
)}
|
|
573
|
+
<span
|
|
574
|
+
key={`in-${value}`}
|
|
575
|
+
className={cn('col-start-1 row-start-1', outgoing !== null && 'roll-arrive')}
|
|
576
|
+
>
|
|
577
|
+
{value}
|
|
578
|
+
</span>
|
|
579
|
+
</span>
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// The pill resizes to whatever it says, Dynamic Island style: the shell
|
|
584
|
+
// animates its width while the outgoing text blurs out and the new text
|
|
585
|
+
// blurs in. The shell's width is driven by the incoming content alone so
|
|
586
|
+
// it never waits for the old text to unmount.
|
|
587
|
+
function IslandHint({
|
|
588
|
+
contentKey,
|
|
589
|
+
tone,
|
|
590
|
+
children,
|
|
591
|
+
}: {
|
|
592
|
+
contentKey: string;
|
|
593
|
+
tone: 'default' | 'error';
|
|
594
|
+
children: ReactNode;
|
|
595
|
+
}) {
|
|
596
|
+
const contentRef = useRef<HTMLSpanElement>(null);
|
|
597
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
598
|
+
const [outgoing, setOutgoing] = useState<{ key: string; node: ReactNode } | null>(null);
|
|
599
|
+
const lastRef = useRef<{ key: string; node: ReactNode }>({ key: contentKey, node: children });
|
|
600
|
+
|
|
601
|
+
useLayoutEffect(() => {
|
|
602
|
+
const previous = lastRef.current;
|
|
603
|
+
lastRef.current = { key: contentKey, node: children };
|
|
604
|
+
if (previous.key === contentKey) return;
|
|
605
|
+
setOutgoing(previous);
|
|
606
|
+
const timer = setTimeout(() => setOutgoing(null), ISLAND_SWAP_MS);
|
|
607
|
+
return () => clearTimeout(timer);
|
|
608
|
+
}, [contentKey, children]);
|
|
609
|
+
|
|
610
|
+
// The content span remounts per key, and its entrance scales it, so
|
|
611
|
+
// re-attach per key and read the unscaled layout width.
|
|
612
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-observe the remounted span
|
|
613
|
+
useLayoutEffect(() => {
|
|
614
|
+
const node = contentRef.current;
|
|
615
|
+
if (!node) return;
|
|
616
|
+
const update = () => setWidth(node.offsetWidth);
|
|
617
|
+
update();
|
|
618
|
+
const observer = new ResizeObserver(update);
|
|
619
|
+
observer.observe(node);
|
|
620
|
+
return () => observer.disconnect();
|
|
621
|
+
}, [contentKey]);
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<div
|
|
625
|
+
data-comment-hint
|
|
626
|
+
className={cn(
|
|
627
|
+
'island-enter grid max-w-[calc(100%-2rem)] justify-items-start overflow-hidden rounded-full border bg-card/95 px-3 py-1.5 text-[11px] text-muted-foreground shadow-floating backdrop-blur',
|
|
628
|
+
'motion-safe:transition-[width] motion-safe:duration-500 motion-safe:ease-[cubic-bezier(0.32,0.72,0,1)]',
|
|
629
|
+
tone === 'error' && 'text-destructive',
|
|
630
|
+
)}
|
|
631
|
+
style={width === null ? undefined : { width: width + 26 }}
|
|
632
|
+
>
|
|
633
|
+
{outgoing && (
|
|
634
|
+
<span
|
|
635
|
+
key={outgoing.key}
|
|
636
|
+
aria-hidden
|
|
637
|
+
className="island-leave col-start-1 row-start-1 truncate whitespace-nowrap"
|
|
638
|
+
>
|
|
639
|
+
{outgoing.node}
|
|
640
|
+
</span>
|
|
641
|
+
)}
|
|
642
|
+
<span
|
|
643
|
+
key={contentKey}
|
|
644
|
+
ref={contentRef}
|
|
645
|
+
className={cn(
|
|
646
|
+
'col-start-1 row-start-1 w-max whitespace-nowrap',
|
|
647
|
+
outgoing && 'island-arrive',
|
|
648
|
+
)}
|
|
649
|
+
>
|
|
650
|
+
{children}
|
|
651
|
+
</span>
|
|
652
|
+
</div>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// `mountKey` re-attaches the observer when the measured card mounts later
|
|
657
|
+
// than the hook's owner (the pin's cards only exist while open or hovered).
|
|
658
|
+
function useMeasuredSize(
|
|
659
|
+
fallback: Size,
|
|
660
|
+
mountKey: unknown = null,
|
|
661
|
+
): [RefObject<HTMLDivElement | null>, Size] {
|
|
662
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
663
|
+
const [size, setSize] = useState<Size>(fallback);
|
|
664
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: mountKey only triggers re-observation
|
|
665
|
+
useLayoutEffect(() => {
|
|
666
|
+
const node = ref.current;
|
|
667
|
+
if (!node) return;
|
|
668
|
+
const update = () => {
|
|
669
|
+
const next = { width: node.offsetWidth, height: node.offsetHeight };
|
|
670
|
+
setSize((previous) =>
|
|
671
|
+
previous.width === next.width && previous.height === next.height ? previous : next,
|
|
672
|
+
);
|
|
673
|
+
};
|
|
674
|
+
update();
|
|
675
|
+
const observer = new ResizeObserver(update);
|
|
676
|
+
observer.observe(node);
|
|
677
|
+
return () => observer.disconnect();
|
|
678
|
+
}, [mountKey]);
|
|
679
|
+
return [ref, size];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function Pin({
|
|
683
|
+
anchor,
|
|
684
|
+
lit,
|
|
685
|
+
children,
|
|
686
|
+
...props
|
|
687
|
+
}: ComponentProps<'button'> & { anchor: ScreenRect; lit: boolean }) {
|
|
688
|
+
return (
|
|
689
|
+
<button
|
|
690
|
+
type="button"
|
|
691
|
+
{...props}
|
|
692
|
+
className={cn(
|
|
693
|
+
'pointer-events-auto absolute flex size-6 items-center justify-center rounded-full rounded-bl-[3px] bg-brand font-mono text-[10px] font-semibold text-brand-foreground ring-2 ring-background shadow-floating nums',
|
|
694
|
+
'transition-[scale,box-shadow] duration-150 ease-swift hover:scale-110 focus-visible:outline-none focus-visible:ring-ring/60',
|
|
695
|
+
lit && 'scale-110',
|
|
696
|
+
props.className,
|
|
697
|
+
)}
|
|
698
|
+
style={{ left: anchor.left, top: anchor.top }}
|
|
699
|
+
>
|
|
700
|
+
{children}
|
|
701
|
+
</button>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function CommentPin({
|
|
706
|
+
comment,
|
|
707
|
+
frame,
|
|
708
|
+
region,
|
|
709
|
+
anchor,
|
|
710
|
+
bounds,
|
|
711
|
+
open,
|
|
712
|
+
hovered,
|
|
713
|
+
onToggle,
|
|
714
|
+
onHover,
|
|
715
|
+
onSave,
|
|
716
|
+
onRemove,
|
|
717
|
+
}: {
|
|
718
|
+
comment: SlideComment;
|
|
719
|
+
frame: ScreenRect;
|
|
720
|
+
region: boolean;
|
|
721
|
+
anchor: ScreenRect;
|
|
722
|
+
bounds: Size;
|
|
723
|
+
open: boolean;
|
|
724
|
+
hovered: boolean;
|
|
725
|
+
onToggle: () => void;
|
|
726
|
+
onHover: (hovering: boolean) => void;
|
|
727
|
+
onSave: (text: string) => Promise<void>;
|
|
728
|
+
onRemove: () => void;
|
|
729
|
+
}) {
|
|
730
|
+
const t = useLocale();
|
|
731
|
+
const lit = open || hovered;
|
|
732
|
+
const [cardRef, cardSize] = useMeasuredSize(
|
|
733
|
+
{ width: 288, height: 120 },
|
|
734
|
+
open ? 'editor' : hovered ? 'preview' : null,
|
|
735
|
+
);
|
|
736
|
+
const card = placeBeside(anchor, cardSize, bounds);
|
|
737
|
+
const meta = `${format(t.inspector.commentLineLabel, { n: comment.line })}${
|
|
738
|
+
region ? ` · ${t.inspector.commentRegionLabel}` : ''
|
|
739
|
+
}`;
|
|
740
|
+
return (
|
|
741
|
+
<>
|
|
742
|
+
{(lit || region) && (
|
|
743
|
+
<div
|
|
744
|
+
className={cn(
|
|
745
|
+
'absolute border border-brand',
|
|
746
|
+
region && !lit && 'border-dashed border-brand/50',
|
|
747
|
+
)}
|
|
748
|
+
style={frame}
|
|
749
|
+
/>
|
|
750
|
+
)}
|
|
751
|
+
<Pin
|
|
752
|
+
anchor={anchor}
|
|
753
|
+
lit={lit}
|
|
754
|
+
aria-label={comment.note}
|
|
755
|
+
aria-expanded={open}
|
|
756
|
+
onClick={onToggle}
|
|
757
|
+
onPointerEnter={() => onHover(true)}
|
|
758
|
+
onPointerLeave={() => onHover(false)}
|
|
759
|
+
onFocus={() => onHover(true)}
|
|
760
|
+
onBlur={() => onHover(false)}
|
|
761
|
+
>
|
|
762
|
+
<MessageSquare className="size-3" />
|
|
763
|
+
</Pin>
|
|
764
|
+
{open ? (
|
|
765
|
+
<CommentEditor
|
|
766
|
+
key={comment.id}
|
|
767
|
+
cardRef={cardRef}
|
|
768
|
+
style={card}
|
|
769
|
+
meta={meta}
|
|
770
|
+
note={comment.note}
|
|
771
|
+
onSave={onSave}
|
|
772
|
+
onRemove={onRemove}
|
|
773
|
+
/>
|
|
774
|
+
) : (
|
|
775
|
+
hovered && (
|
|
776
|
+
<div
|
|
777
|
+
ref={cardRef}
|
|
778
|
+
data-comment-preview
|
|
779
|
+
className="island-arrive pointer-events-none absolute w-72 origin-top-left rounded-[10px] border border-border/80 bg-card text-[12.5px] shadow-overlay"
|
|
780
|
+
style={card}
|
|
781
|
+
>
|
|
782
|
+
<div className="truncate px-3 pt-2 font-mono text-[10px] text-muted-foreground">
|
|
783
|
+
{meta}
|
|
784
|
+
</div>
|
|
785
|
+
<p className="px-3 pt-1 pb-3 leading-relaxed break-words whitespace-pre-wrap">
|
|
786
|
+
{comment.note}
|
|
787
|
+
</p>
|
|
788
|
+
</div>
|
|
789
|
+
)
|
|
790
|
+
)}
|
|
791
|
+
</>
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function CommentEditor({
|
|
796
|
+
cardRef,
|
|
797
|
+
style,
|
|
798
|
+
meta,
|
|
799
|
+
note,
|
|
800
|
+
onSave,
|
|
801
|
+
onRemove,
|
|
802
|
+
}: {
|
|
803
|
+
cardRef: RefObject<HTMLDivElement | null>;
|
|
804
|
+
style: { left: number; top: number };
|
|
805
|
+
meta: string;
|
|
806
|
+
note: string;
|
|
807
|
+
onSave: (text: string) => Promise<void>;
|
|
808
|
+
onRemove: () => void;
|
|
809
|
+
}) {
|
|
810
|
+
const t = useLocale();
|
|
811
|
+
const [text, setText] = useState(note);
|
|
812
|
+
const [saving, setSaving] = useState(false);
|
|
813
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
814
|
+
const dirty = text.trim() !== note && text.trim() !== '';
|
|
815
|
+
|
|
816
|
+
useEffect(() => {
|
|
817
|
+
const node = textareaRef.current;
|
|
818
|
+
if (!node) return;
|
|
819
|
+
node.focus({ preventScroll: true });
|
|
820
|
+
node.setSelectionRange(node.value.length, node.value.length);
|
|
821
|
+
}, []);
|
|
822
|
+
|
|
823
|
+
const save = async () => {
|
|
824
|
+
if (!dirty || saving) return;
|
|
825
|
+
setSaving(true);
|
|
826
|
+
try {
|
|
827
|
+
await onSave(text.trim());
|
|
828
|
+
} finally {
|
|
829
|
+
setSaving(false);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
return (
|
|
834
|
+
<CommentCard ref={cardRef} style={style} data-comment-editor>
|
|
835
|
+
<div className="flex h-8 items-center justify-between gap-2 pr-1.5 pl-3">
|
|
836
|
+
<span className="truncate font-mono text-[10px] text-muted-foreground">{meta}</span>
|
|
837
|
+
<button
|
|
838
|
+
type="button"
|
|
839
|
+
onClick={onRemove}
|
|
840
|
+
disabled={saving}
|
|
841
|
+
className="flex size-6 shrink-0 items-center justify-center rounded-[5px] text-muted-foreground transition-[color,background-color,scale] duration-150 hover:bg-muted hover:text-destructive active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
842
|
+
aria-label={t.inspector.commentDeleteAria}
|
|
843
|
+
title={t.inspector.commentDeleteAria}
|
|
844
|
+
>
|
|
845
|
+
<Trash2 className="size-3.5" />
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
<CommentTextarea
|
|
849
|
+
ref={textareaRef}
|
|
850
|
+
value={text}
|
|
851
|
+
disabled={saving}
|
|
852
|
+
onChange={(event) => setText(event.target.value)}
|
|
853
|
+
onSubmit={save}
|
|
854
|
+
className="pt-0"
|
|
855
|
+
/>
|
|
856
|
+
<CommentCardFooter>
|
|
857
|
+
<SendButton icon={Check} label={t.common.save} onClick={save} disabled={saving || !dirty} />
|
|
858
|
+
</CommentCardFooter>
|
|
859
|
+
</CommentCard>
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function Composer({
|
|
864
|
+
frame,
|
|
865
|
+
region,
|
|
866
|
+
anchor,
|
|
867
|
+
bounds,
|
|
868
|
+
onSubmit,
|
|
869
|
+
}: {
|
|
870
|
+
frame: ScreenRect;
|
|
871
|
+
region: boolean;
|
|
872
|
+
anchor: ScreenRect;
|
|
873
|
+
bounds: Size;
|
|
874
|
+
onSubmit: (text: string) => Promise<void>;
|
|
875
|
+
}) {
|
|
876
|
+
const t = useLocale();
|
|
877
|
+
const [text, setText] = useState('');
|
|
878
|
+
const [submitting, setSubmitting] = useState(false);
|
|
879
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
880
|
+
const [cardRef, cardSize] = useMeasuredSize({ width: 288, height: 112 });
|
|
881
|
+
const card = placeBeside(anchor, cardSize, bounds);
|
|
882
|
+
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
885
|
+
}, []);
|
|
886
|
+
|
|
887
|
+
const post = async () => {
|
|
888
|
+
const trimmed = text.trim();
|
|
889
|
+
if (!trimmed || submitting) return;
|
|
890
|
+
setSubmitting(true);
|
|
891
|
+
try {
|
|
892
|
+
await onSubmit(trimmed);
|
|
893
|
+
} finally {
|
|
894
|
+
setSubmitting(false);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<>
|
|
900
|
+
<div className={cn('absolute border border-brand', region && 'bg-brand/5')} style={frame} />
|
|
901
|
+
<Pin anchor={anchor} lit aria-hidden tabIndex={-1}>
|
|
902
|
+
<MessageSquare className="size-3" />
|
|
903
|
+
</Pin>
|
|
904
|
+
<CommentCard ref={cardRef} style={card} data-comment-composer>
|
|
905
|
+
<CommentTextarea
|
|
906
|
+
ref={textareaRef}
|
|
907
|
+
value={text}
|
|
908
|
+
disabled={submitting}
|
|
909
|
+
onChange={(event) => setText(event.target.value)}
|
|
910
|
+
onSubmit={post}
|
|
911
|
+
placeholder={t.inspector.commentPlaceholder}
|
|
912
|
+
/>
|
|
913
|
+
<CommentCardFooter>
|
|
914
|
+
<SendButton
|
|
915
|
+
icon={ArrowUp}
|
|
916
|
+
label={t.inspector.commentPost}
|
|
917
|
+
onClick={post}
|
|
918
|
+
disabled={submitting || !text.trim()}
|
|
919
|
+
/>
|
|
920
|
+
</CommentCardFooter>
|
|
921
|
+
</CommentCard>
|
|
922
|
+
</>
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// One quiet surface: the text sits directly on the card instead of inside
|
|
927
|
+
// a second framed box, and the only strong element is the send button.
|
|
928
|
+
function CommentCard({
|
|
929
|
+
className,
|
|
930
|
+
...props
|
|
931
|
+
}: ComponentProps<'div'> & { ref: RefObject<HTMLDivElement | null> }) {
|
|
932
|
+
return (
|
|
933
|
+
<div
|
|
934
|
+
{...props}
|
|
935
|
+
className={cn(
|
|
936
|
+
'island-arrive pointer-events-auto absolute w-72 origin-top-left rounded-[10px] border border-border/80 bg-card shadow-overlay',
|
|
937
|
+
className,
|
|
938
|
+
)}
|
|
939
|
+
/>
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function CommentTextarea({
|
|
944
|
+
onSubmit,
|
|
945
|
+
className,
|
|
946
|
+
...props
|
|
947
|
+
}: Omit<ComponentProps<'textarea'>, 'onSubmit'> & {
|
|
948
|
+
ref: RefObject<HTMLTextAreaElement | null>;
|
|
949
|
+
onSubmit: () => void;
|
|
950
|
+
}) {
|
|
951
|
+
return (
|
|
952
|
+
<textarea
|
|
953
|
+
rows={2}
|
|
954
|
+
{...props}
|
|
955
|
+
onKeyDown={(event) => {
|
|
956
|
+
if (event.nativeEvent.isComposing) return;
|
|
957
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
958
|
+
event.preventDefault();
|
|
959
|
+
onSubmit();
|
|
960
|
+
}
|
|
961
|
+
}}
|
|
962
|
+
className={cn(
|
|
963
|
+
'field-sizing-content block max-h-40 min-h-[3.25rem] w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-[12.5px] leading-[1.5] text-foreground outline-none',
|
|
964
|
+
'placeholder:text-muted-foreground/60 disabled:opacity-60',
|
|
965
|
+
className,
|
|
966
|
+
)}
|
|
967
|
+
/>
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function CommentCardFooter({ children }: { children: ReactNode }) {
|
|
972
|
+
return (
|
|
973
|
+
<div className="flex items-center justify-between gap-2 px-2 pt-0.5 pb-2 pl-3">
|
|
974
|
+
<kbd className="rounded-[4px] border border-border/70 bg-muted/60 px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.04em] text-muted-foreground">
|
|
975
|
+
{SUBMIT_SHORTCUT}
|
|
976
|
+
</kbd>
|
|
977
|
+
{children}
|
|
978
|
+
</div>
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function SendButton({
|
|
983
|
+
icon: Icon,
|
|
984
|
+
label,
|
|
985
|
+
onClick,
|
|
986
|
+
disabled,
|
|
987
|
+
}: {
|
|
988
|
+
icon: typeof ArrowUp;
|
|
989
|
+
label: string;
|
|
990
|
+
onClick: () => void;
|
|
991
|
+
disabled: boolean;
|
|
992
|
+
}) {
|
|
993
|
+
return (
|
|
994
|
+
<button
|
|
995
|
+
type="button"
|
|
996
|
+
onClick={onClick}
|
|
997
|
+
disabled={disabled}
|
|
998
|
+
aria-label={label}
|
|
999
|
+
title={label}
|
|
1000
|
+
className={cn(
|
|
1001
|
+
'flex size-7 shrink-0 items-center justify-center rounded-full transition-[background-color,color,scale,opacity] duration-150 ease-swift',
|
|
1002
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
|
1003
|
+
disabled
|
|
1004
|
+
? 'bg-muted text-muted-foreground/50'
|
|
1005
|
+
: 'bg-brand text-brand-foreground shadow-[inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)] hover:brightness-105 active:scale-90',
|
|
1006
|
+
)}
|
|
1007
|
+
>
|
|
1008
|
+
<Icon className="size-3.5" strokeWidth={2.5} />
|
|
1009
|
+
</button>
|
|
1010
|
+
);
|
|
1011
|
+
}
|