返回 AiToEarn
live-insert-ui.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-insert-ui.mjs
1 /**
2 * Pure helpers for live-mode insert UI (browser + tests).
3 * Kept separate from live-browser.js so insert logic is unit-testable.
4 */
5
6 export const PLACEHOLDER_DEFAULT_HEIGHT = 80;
7 export const PLACEHOLDER_MIN_HEIGHT = 48;
8 export const PLACEHOLDER_MIN_WIDTH = 120;
9
10 /** @typedef {'before' | 'after'} InsertPosition */
11 /** @typedef {'row' | 'column'} InsertAxis */
12
13 /**
14 * Infer sibling flow axis from a container's computed layout styles.
15 * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style
16 * @returns {InsertAxis}
17 */
18 export function detectInsertAxisFromStyle(style) {
19 const display = style?.display || 'block';
20 if (display.includes('flex')) {
21 const dir = style.flexDirection || 'row';
22 return dir.startsWith('row') ? 'row' : 'column';
23 }
24 if (display === 'grid' || display === 'inline-grid') {
25 const flow = style.gridAutoFlow || 'row';
26 if (flow.includes('column')) return 'column';
27 const cols = (style.gridTemplateColumns || '').trim();
28 if (cols && cols !== 'none') {
29 const colCount = cols.split(/\s+/).filter(Boolean).length;
30 if (colCount > 1) return 'row';
31 }
32 return 'row';
33 }
34 return 'column';
35 }
36
37 /**
38 * Pick insertion side from pointer position against an anchor element box.
39 * @param {number} clientX
40 * @param {number} clientY
41 * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
42 * @param {InsertAxis} [axis]
43 * @returns {InsertPosition}
44 */
45 export function computeInsertPosition(clientX, clientY, rect, axis = 'column') {
46 if (!rect) return 'after';
47 if (axis === 'row') {
48 if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after';
49 const mid = rect.left + rect.width / 2;
50 return clientX < mid ? 'before' : 'after';
51 }
52 if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after';
53 const mid = rect.top + rect.height / 2;
54 return clientY < mid ? 'before' : 'after';
55 }
56
57 /**
58 * Whether Create is allowed for an insert session.
59 * Requires a non-empty prompt OR at least one annotation.
60 */
61 export function canCreateInsert({ prompt, comments, strokes }) {
62 const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
63 const hasComments = Array.isArray(comments) && comments.length > 0;
64 const hasStrokes = Array.isArray(strokes) && strokes.some(
65 (s) => Array.isArray(s?.points) && s.points.length >= 2,
66 );
67 return hasPrompt || hasComments || hasStrokes;
68 }
69
70 /** Tooltip/title when Create is disabled. */
71 export function insertCreateDisabledReason({ prompt, comments, strokes }) {
72 if (canCreateInsert({ prompt, comments, strokes })) return null;
73 return 'Add a prompt or annotate the placeholder to create';
74 }
75
76 /**
77 * Fixed-position insert line coordinates (viewport px).
78 * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
79 * @param {InsertPosition} position
80 * @param {InsertAxis} [axis]
81 */
82 export function insertLineCoords(rect, position, axis = 'column') {
83 if (axis === 'row') {
84 const right = rect.right ?? rect.left + rect.width;
85 const x = position === 'before' ? rect.left - 2 : right + 2;
86 return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
87 }
88 const bottom = rect.bottom ?? rect.top + rect.height;
89 const y = position === 'before' ? rect.top - 2 : bottom + 2;
90 return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
91 }
92
93 /** Cursor while hovering an insert boundary. */
94 export function cursorForInsertAxis(axis) {
95 return axis === 'row' ? 'ew-resize' : 'ns-resize';
96 }
97
98 function groupSiblingRows(siblings, rowThreshold = 8) {
99 const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
100 const rows = [];
101 for (const entry of sorted) {
102 let placed = false;
103 for (const row of rows) {
104 if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
105 row.push(entry);
106 placed = true;
107 break;
108 }
109 }
110 if (!placed) rows.push([entry]);
111 }
112 return rows;
113 }
114
115 function horizontalOverlap(a, b) {
116 const left = Math.max(a.left, b.left);
117 const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width);
118 return Math.max(0, right - left);
119 }
120
121 /**
122 * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks).
123 * @param {number} clientX
124 * @param {number} clientY
125 * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings
126 * @param {{ slop?: number, minOverlap?: number }} [opts]
127 */
128 export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) {
129 if (!Array.isArray(siblings) || siblings.length < 2) return null;
130 const slop = opts.slop ?? 12;
131 const minOverlap = opts.minOverlap ?? 0.25;
132
133 for (const row of groupSiblingRows(siblings)) {
134 if (row.length < 2) continue;
135 const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
136 for (let i = 0; i < sorted.length - 1; i++) {
137 const a = sorted[i];
138 const b = sorted[i + 1];
139 const aRight = a.rect.right ?? a.rect.left + a.rect.width;
140 const bLeft = b.rect.left;
141 if (bLeft <= aRight) continue;
142 const top = Math.max(a.rect.top, b.rect.top);
143 const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
144 const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height;
145 const bottom = Math.min(aBottom, bBottom);
146 const span = bottom - top;
147 const minH = Math.min(a.rect.height, b.rect.height);
148 if (span < minH * minOverlap) continue;
149
150 const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
151 const inY = clientY >= top - slop && clientY <= bottom + slop;
152 if (!inX || !inY) continue;
153
154 const midX = (aRight + bLeft) / 2;
155 return {
156 anchor: b.el,
157 position: 'before',
158 axis: 'row',
159 line: { axis: 'row', left: midX, top, width: 0, height: span },
160 };
161 }
162 }
163
164 const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
165 for (let i = 0; i < sortedCol.length - 1; i++) {
166 const a = sortedCol[i];
167 const b = sortedCol[i + 1];
168 const overlap = horizontalOverlap(a.rect, b.rect);
169 const minW = Math.min(a.rect.width, b.rect.width);
170 if (overlap < minW * minOverlap) continue;
171
172 const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
173 const gapTop = aBottom;
174 const gapBottom = b.rect.top;
175 if (gapBottom <= gapTop) continue;
176
177 const overlapLeft = Math.max(a.rect.left, b.rect.left);
178 const overlapRight = Math.min(
179 a.rect.right ?? a.rect.left + a.rect.width,
180 b.rect.right ?? b.rect.left + b.rect.width,
181 );
182 const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
183 const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
184 if (!inY || !inX) continue;
185
186 const midY = (gapTop + gapBottom) / 2;
187 return {
188 anchor: b.el,
189 position: 'before',
190 axis: 'column',
191 line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 },
192 };
193 }
194
195 return null;
196 }
197
198 /**
199 * Resolve insert hover target, side, axis, and indicator line for the pointer.
200 */
201 export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
202 const gap = hitSiblingInsertGap(clientX, clientY, siblings);
203 if (gap) return gap;
204
205 const position = computeInsertPosition(clientX, clientY, rect, axis);
206 const line = insertLineCoords(rect, position, axis);
207 return { anchor: target, position, axis, line };
208 }
209
210 /**
211 * How the in-flow placeholder should participate in layout.
212 * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px.
213 * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }}
214 */
215 export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
216 const display = parentDisplay || 'block';
217 const w = Number.isFinite(parentWidth) ? parentWidth : 0;
218
219 if (axis === 'row') {
220 if (display.includes('flex')) {
221 const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
222 ? anchorFlex
223 : '1 1 0';
224 return { kind: 'flex', flex, minWidth: 0 };
225 }
226 if (display === 'grid' || display === 'inline-grid') {
227 return { kind: 'auto' };
228 }
229 }
230
231 if (w >= PLACEHOLDER_MIN_WIDTH) {
232 return { kind: 'percent' };
233 }
234
235 return {
236 kind: 'explicit',
237 width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
238 };
239 }
240
241 /** Width kinds that need materializing to px before edge-resize. */
242 export function placeholderWidthIsImplicit(kind) {
243 return kind === 'flex' || kind === 'percent' || kind === 'auto';
244 }
245
246 /**
247 * Clamp user-resized placeholder dimensions.
248 */
249 export function clampPlaceholderSize(width, height, parentWidth, opts = {}) {
250 const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH;
251 const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT;
252 const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW);
253 return {
254 width: Math.min(maxW, Math.max(minW, Math.round(width))),
255 height: Math.max(minH, Math.round(height)),
256 };
257 }
258
259 /** CSS cursor for a placeholder edge resize handle. */
260 export function cursorForPlaceholderEdge(edge) {
261 if (edge === 'n' || edge === 's') return 'ns-resize';
262 if (edge === 'e' || edge === 'w') return 'ew-resize';
263 return 'default';
264 }
265
266 /**
267 * Compute placeholder box after dragging one edge (in-flow margins shift for n/w).
268 * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start
269 * @param {'n'|'e'|'s'|'w'} edge
270 * @param {number} dx pointer delta X since drag start
271 * @param {number} dy pointer delta Y since drag start
272 * @param {number} parentWidth
273 */
274 export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) {
275 const base = {
276 width: start.width,
277 height: start.height,
278 marginLeft: start.marginLeft ?? 0,
279 marginTop: start.marginTop ?? 0,
280 };
281 if (edge === 'e') base.width = start.width + dx;
282 else if (edge === 'w') {
283 base.width = start.width - dx;
284 base.marginLeft = start.marginLeft + dx;
285 } else if (edge === 's') base.height = start.height + dy;
286 else if (edge === 'n') {
287 base.height = start.height - dy;
288 base.marginTop = start.marginTop + dy;
289 }
290
291 const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts);
292 if (edge === 'w') {
293 base.marginLeft = start.marginLeft + start.width - clamped.width;
294 } else if (edge === 'n') {
295 base.marginTop = start.marginTop + start.height - clamped.height;
296 }
297
298 return {
299 width: clamped.width,
300 height: clamped.height,
301 marginLeft: Math.round(base.marginLeft),
302 marginTop: Math.round(base.marginTop),
303 };
304 }
305
306 /** Pick and insert toggles are independent but turning one ON turns the other OFF. */
307 export function applyPickToggle(pickActive, insertActive) {
308 const nextPick = !pickActive;
309 return {
310 pickActive: nextPick,
311 insertActive: nextPick ? false : insertActive,
312 };
313 }
314
315 export function applyInsertToggle(pickActive, insertActive) {
316 const nextInsert = !insertActive;
317 return {
318 pickActive: nextInsert ? false : pickActive,
319 insertActive: nextInsert,
320 };
321 }
322
323 /**
324 * Build the browser generate payload for insert mode.
325 */
326 export function buildInsertGeneratePayload({
327 id,
328 count,
329 pageUrl,
330 anchorContext,
331 position,
332 placeholder,
333 freeformPrompt,
334 comments,
335 strokes,
336 screenshotPath,
337 }) {
338 const payload = {
339 type: 'generate',
340 mode: 'insert',
341 id,
342 count,
343 pageUrl,
344 insert: {
345 position,
346 anchor: anchorContext,
347 },
348 placeholder,
349 freeformPrompt: freeformPrompt?.trim() || undefined,
350 };
351 if (comments?.length) payload.comments = comments;
352 if (strokes?.length) payload.strokes = strokes;
353 if (screenshotPath) payload.screenshotPath = screenshotPath;
354 return payload;
355 }
356
357 /**
358 * Whether a variant wrapper is currently shown (handles `hidden` and display:none).
359 * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el
360 */
361 export function isVariantShown(el) {
362 if (!el) return false;
363 if (el.hidden) return false;
364 if (el.style?.display === 'none') return false;
365 return true;
366 }
367
368 /**
369 * Show or hide a variant wrapper for cycling.
370 * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el
371 * @param {boolean} shown
372 */
373 export function setVariantShown(el, shown) {
374 if (!el) return;
375 if (shown) {
376 el.removeAttribute?.('hidden');
377 if (el.style) el.style.display = '';
378 } else {
379 el.setAttribute?.('hidden', '');
380 if (el.style) el.style.display = 'none';
381 }
382 }
383
384 /**
385 * Pick the best live anchor during an insert session (placeholder until variants land).
386 * @param {{
387 * wrapper?: unknown,
388 * variantCount?: number,
389 * visibleVariant?: number,
390 * placeholder?: unknown,
391 * insertAnchor?: unknown,
392 * pickVariantContent?: (wrapper: unknown, index: number) => unknown,
393 * }} opts
394 */
395 export function resolveInsertSessionAnchor(opts) {
396 const {
397 wrapper,
398 variantCount = 0,
399 visibleVariant = 0,
400 placeholder,
401 insertAnchor,
402 pickVariantContent,
403 } = opts || {};
404 if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) {
405 const vis = pickVariantContent(wrapper, visibleVariant);
406 if (vis) return vis;
407 }
408 return placeholder || insertAnchor || null;
409 }
410
411 /**
412 * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box.
413 * @param {{
414 * tagName?: string,
415 * className?: string,
416 * textContent?: string,
417 * }} anchor
418 * @param {{
419 * offsetWidth?: number,
420 * offsetHeight?: number,
421 * style?: { marginLeft?: string, marginTop?: string },
422 * }} placeholder
423 * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta
424 */
425 export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) {
426 return {
427 width: Math.round(placeholder.offsetWidth || 0),
428 height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
429 marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0,
430 marginTop: parseFloat(placeholder.style?.marginTop || '') || 0,
431 position,
432 layoutAxis: layoutAxis || 'column',
433 anchorTag: anchor.tagName || 'DIV',
434 anchorClasses: anchor.className || '',
435 anchorText: (anchor.textContent || '').trim().slice(0, 120),
436 };
437 }
438
439 /**
440 * Re-find an insert anchor after framework HMR replaced the live DOM node.
441 * @param {Pick<Document, 'body' | 'querySelectorAll'>} doc
442 * @param {ReturnType<typeof buildInsertPlaceholderSnapshot> | null | undefined} snapshot
443 * @param {Element | null | undefined} liveAnchor
444 */
445 export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) {
446 if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor;
447 if (!snapshot) return null;
448 const tag = (snapshot.anchorTag || 'div').toLowerCase();
449 const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
450 const needle = snapshot.anchorText || '';
451 const sel = cls ? `${tag}.${cls}` : tag;
452 const candidates = doc.querySelectorAll(sel);
453 for (const candidate of candidates) {
454 if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
455 return candidate;
456 }
457 return null;
458 }
459
459 lines Plain Text