@sudodevstudio/astro-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/agent/agent-fallback.d.ts +50 -0
  4. package/dist/agent/agent-fallback.d.ts.map +1 -0
  5. package/dist/agent/agent-fallback.js +31 -0
  6. package/dist/agent/agent-fallback.js.map +1 -0
  7. package/dist/agent/cli-agent-fallback.d.ts +79 -0
  8. package/dist/agent/cli-agent-fallback.d.ts.map +1 -0
  9. package/dist/agent/cli-agent-fallback.js +1056 -0
  10. package/dist/agent/cli-agent-fallback.js.map +1 -0
  11. package/dist/integration/index.d.ts +28 -0
  12. package/dist/integration/index.d.ts.map +1 -0
  13. package/dist/integration/index.js +389 -0
  14. package/dist/integration/index.js.map +1 -0
  15. package/dist/resolver/astro-resolver.d.ts +28 -0
  16. package/dist/resolver/astro-resolver.d.ts.map +1 -0
  17. package/dist/resolver/astro-resolver.js +835 -0
  18. package/dist/resolver/astro-resolver.js.map +1 -0
  19. package/dist/resolver/types.d.ts +39 -0
  20. package/dist/resolver/types.d.ts.map +1 -0
  21. package/dist/resolver/types.js +2 -0
  22. package/dist/resolver/types.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +150 -0
  24. package/dist/shared/protocol.d.ts.map +1 -0
  25. package/dist/shared/protocol.js +28 -0
  26. package/dist/shared/protocol.js.map +1 -0
  27. package/dist/shared/selection-context.d.ts +75 -0
  28. package/dist/shared/selection-context.d.ts.map +1 -0
  29. package/dist/shared/selection-context.js +2 -0
  30. package/dist/shared/selection-context.js.map +1 -0
  31. package/dist/shared/visual-components.d.ts +15 -0
  32. package/dist/shared/visual-components.d.ts.map +1 -0
  33. package/dist/shared/visual-components.js +2 -0
  34. package/dist/shared/visual-components.js.map +1 -0
  35. package/dist/toolbar/action-model.d.ts +20 -0
  36. package/dist/toolbar/action-model.d.ts.map +1 -0
  37. package/dist/toolbar/action-model.js +49 -0
  38. package/dist/toolbar/action-model.js.map +1 -0
  39. package/dist/toolbar/app.d.ts +3 -0
  40. package/dist/toolbar/app.d.ts.map +1 -0
  41. package/dist/toolbar/app.js +274 -0
  42. package/dist/toolbar/app.js.map +1 -0
  43. package/dist/toolbar/chat-drawer.d.ts +111 -0
  44. package/dist/toolbar/chat-drawer.d.ts.map +1 -0
  45. package/dist/toolbar/chat-drawer.js +1575 -0
  46. package/dist/toolbar/chat-drawer.js.map +1 -0
  47. package/dist/toolbar/chat-windows.d.ts +62 -0
  48. package/dist/toolbar/chat-windows.d.ts.map +1 -0
  49. package/dist/toolbar/chat-windows.js +287 -0
  50. package/dist/toolbar/chat-windows.js.map +1 -0
  51. package/dist/toolbar/contextual-actions.d.ts +24 -0
  52. package/dist/toolbar/contextual-actions.d.ts.map +1 -0
  53. package/dist/toolbar/contextual-actions.js +371 -0
  54. package/dist/toolbar/contextual-actions.js.map +1 -0
  55. package/dist/toolbar/diagnostic-actions.d.ts +22 -0
  56. package/dist/toolbar/diagnostic-actions.d.ts.map +1 -0
  57. package/dist/toolbar/diagnostic-actions.js +127 -0
  58. package/dist/toolbar/diagnostic-actions.js.map +1 -0
  59. package/dist/toolbar/layers.d.ts +26 -0
  60. package/dist/toolbar/layers.d.ts.map +1 -0
  61. package/dist/toolbar/layers.js +31 -0
  62. package/dist/toolbar/layers.js.map +1 -0
  63. package/dist/toolbar/overlay.d.ts +40 -0
  64. package/dist/toolbar/overlay.d.ts.map +1 -0
  65. package/dist/toolbar/overlay.js +619 -0
  66. package/dist/toolbar/overlay.js.map +1 -0
  67. package/dist/visual/capability-resolver.d.ts +11 -0
  68. package/dist/visual/capability-resolver.d.ts.map +1 -0
  69. package/dist/visual/capability-resolver.js +69 -0
  70. package/dist/visual/capability-resolver.js.map +1 -0
  71. package/dist/visual/command-engine.d.ts +10 -0
  72. package/dist/visual/command-engine.d.ts.map +1 -0
  73. package/dist/visual/command-engine.js +199 -0
  74. package/dist/visual/command-engine.js.map +1 -0
  75. package/dist/visual/commands.d.ts +36 -0
  76. package/dist/visual/commands.d.ts.map +1 -0
  77. package/dist/visual/commands.js +2 -0
  78. package/dist/visual/commands.js.map +1 -0
  79. package/dist/visual/patch-transactions.d.ts +57 -0
  80. package/dist/visual/patch-transactions.d.ts.map +1 -0
  81. package/dist/visual/patch-transactions.js +259 -0
  82. package/dist/visual/patch-transactions.js.map +1 -0
  83. package/dist/vite/build-ai-plugin.d.ts +11 -0
  84. package/dist/vite/build-ai-plugin.d.ts.map +1 -0
  85. package/dist/vite/build-ai-plugin.js +45 -0
  86. package/dist/vite/build-ai-plugin.js.map +1 -0
  87. package/package.json +85 -0
  88. package/src/agent/agent-fallback.ts +95 -0
  89. package/src/agent/cli-agent-fallback.ts +1382 -0
  90. package/src/integration/index.ts +475 -0
  91. package/src/resolver/astro-resolver.ts +1024 -0
  92. package/src/resolver/types.ts +47 -0
  93. package/src/shared/protocol.ts +181 -0
  94. package/src/shared/selection-context.ts +91 -0
  95. package/src/shared/visual-components.ts +16 -0
  96. package/src/toolbar/action-model.ts +67 -0
  97. package/src/toolbar/app.ts +294 -0
  98. package/src/toolbar/chat-drawer.ts +1742 -0
  99. package/src/toolbar/chat-windows.ts +331 -0
  100. package/src/toolbar/contextual-actions.ts +401 -0
  101. package/src/toolbar/diagnostic-actions.ts +151 -0
  102. package/src/toolbar/layers.ts +32 -0
  103. package/src/toolbar/overlay.ts +638 -0
  104. package/src/visual/capability-resolver.ts +83 -0
  105. package/src/visual/command-engine.ts +250 -0
  106. package/src/visual/commands.ts +37 -0
  107. package/src/visual/patch-transactions.ts +270 -0
  108. package/src/vite/build-ai-plugin.ts +46 -0
@@ -0,0 +1,638 @@
1
+ import type { SelectionContext, SourceInsertionZone } from '../shared/selection-context.js';
2
+ import type { DeterministicVisualCommand } from '../visual/commands.js';
3
+ import { middleTruncatePath } from './action-model.js';
4
+ import { ContextualActionBar } from './contextual-actions.js';
5
+ import { EDITOR_LAYERS } from './layers.js';
6
+
7
+ const NODE_SELECTOR = '[data-astro-ai-id]';
8
+ const MARQUEE_THRESHOLD = 6;
9
+ const MAX_MARQUEE_SELECTION = 50;
10
+
11
+ type AnchorPoint = { x: number; y: number };
12
+ type SelectionMode = 'replace' | 'add' | 'refresh';
13
+ type PendingSelection = { element: HTMLElement; mode: SelectionMode; point?: AnchorPoint };
14
+ type SelectedItem = {
15
+ element: HTMLElement;
16
+ context: SelectionContext;
17
+ highlight: HTMLDivElement;
18
+ label: HTMLSpanElement;
19
+ };
20
+ type MarqueeOrigin = AnchorPoint & { additive: boolean };
21
+
22
+ export type SelectionOverlayCallbacks = {
23
+ onInspect(nodeId: string): void;
24
+ onActiveChange(active: boolean): void;
25
+ onCommand(command: DeterministicVisualCommand): void;
26
+ onAskAI(contexts: SelectionContext[], elements: HTMLElement[]): void;
27
+ onClear(): void;
28
+ onSelectionChange(contexts: SelectionContext[], elements: HTMLElement[]): void;
29
+ onSelectionAnchorChange(rect?: DOMRect): void;
30
+ };
31
+
32
+ export class SelectionOverlay {
33
+ readonly #hover: HTMLDivElement;
34
+ readonly #hoverLabel: HTMLSpanElement;
35
+ readonly #hoverTooltip: HTMLSpanElement;
36
+ readonly #marquee: HTMLDivElement;
37
+ readonly #actions: ContextualActionBar;
38
+ readonly #callbacks: SelectionOverlayCallbacks;
39
+ readonly #selected = new Map<string, SelectedItem>();
40
+ readonly #pending = new Map<string, PendingSelection>();
41
+ readonly #insertionControls = new Map<string, { zone: SourceInsertionZone; button: HTMLButtonElement }>();
42
+ #active = false;
43
+ #enabled = false;
44
+ #primaryNodeId: string | undefined;
45
+ #tooltipTimer: number | undefined;
46
+ #mutationTimer: number | undefined;
47
+ #observer: MutationObserver | undefined;
48
+ #hoveredElement: HTMLElement | undefined;
49
+ #marqueeOrigin: MarqueeOrigin | undefined;
50
+ #marqueeActive = false;
51
+ #suppressClick = false;
52
+ #viewportFrame: number | undefined;
53
+ readonly #tabIndexes = new Map<HTMLElement, string | null>();
54
+
55
+ constructor(callbacks: SelectionOverlayCallbacks) {
56
+ this.#callbacks = callbacks;
57
+ [this.#hover, this.#hoverLabel, this.#hoverTooltip] = createHighlight(false);
58
+ this.#marquee = createMarquee();
59
+ this.#actions = new ContextualActionBar({
60
+ onCommand: callbacks.onCommand,
61
+ onAskAI: (contexts) => callbacks.onAskAI(contexts, this.#elementsFor(contexts)),
62
+ onClear: () => this.clearSelection(),
63
+ onClickThrough: (target) => this.clickThrough(target),
64
+ });
65
+ document.documentElement.append(this.#hover, this.#marquee);
66
+ }
67
+
68
+ get active(): boolean {
69
+ return this.#active;
70
+ }
71
+
72
+ /**
73
+ * Resolves the exact elements behind a selection. A source node rendered in a
74
+ * loop yields many DOM nodes sharing one id, so only the overlay knows which
75
+ * repetition was actually picked.
76
+ */
77
+ #elementsFor(contexts: SelectionContext[]): HTMLElement[] {
78
+ return contexts.flatMap(({ selectedNode }) => {
79
+ const element = this.#selected.get(selectedNode.nodeId)?.element;
80
+ return element === undefined ? [] : [element];
81
+ });
82
+ }
83
+
84
+ enable(): void {
85
+ if (this.#enabled) return;
86
+ this.#enabled = true;
87
+ document.addEventListener('contextmenu', this.#onContextMenu, true);
88
+ document.addEventListener('scroll', this.#onViewportChange, true);
89
+ window.addEventListener('resize', this.#onViewportChange);
90
+ this.#observer = new MutationObserver(this.#onMutations);
91
+ this.#observer.observe(document.documentElement, { childList: true, subtree: true });
92
+ this.#makeKeyboardReachable();
93
+ document.addEventListener('focusin', this.#onFocusIn, true);
94
+ document.addEventListener('keydown', this.#onSelectionKeyDown, true);
95
+ }
96
+
97
+ disable(): void {
98
+ if (!this.#enabled) return;
99
+ this.stop();
100
+ this.clearSelection();
101
+ this.#enabled = false;
102
+ document.removeEventListener('contextmenu', this.#onContextMenu, true);
103
+ document.removeEventListener('scroll', this.#onViewportChange, true);
104
+ window.removeEventListener('resize', this.#onViewportChange);
105
+ this.#observer?.disconnect();
106
+ this.#observer = undefined;
107
+ document.removeEventListener('focusin', this.#onFocusIn, true);
108
+ document.removeEventListener('keydown', this.#onSelectionKeyDown, true);
109
+ for (const [element, previous] of this.#tabIndexes) {
110
+ if (previous === null) element.removeAttribute('tabindex');
111
+ else element.setAttribute('tabindex', previous);
112
+ }
113
+ this.#tabIndexes.clear();
114
+ if (this.#viewportFrame !== undefined) window.cancelAnimationFrame(this.#viewportFrame);
115
+ this.#viewportFrame = undefined;
116
+ if (this.#mutationTimer !== undefined) window.clearTimeout(this.#mutationTimer);
117
+ }
118
+
119
+ start(): void {
120
+ if (!this.#enabled) this.enable();
121
+ if (this.#active) return;
122
+ this.#active = true;
123
+ this.#callbacks.onActiveChange(true);
124
+ document.addEventListener('pointerdown', this.#onPointerDown, true);
125
+ document.addEventListener('pointermove', this.#onPointerMove, true);
126
+ document.addEventListener('pointerup', this.#onPointerUp, true);
127
+ document.addEventListener('pointerleave', this.#onPointerLeave, true);
128
+ document.addEventListener('click', this.#onClick, true);
129
+ this.#setInsertionControlsVisible(true);
130
+ }
131
+
132
+ stop(): void {
133
+ if (!this.#active) return;
134
+ this.#active = false;
135
+ this.#callbacks.onActiveChange(false);
136
+ this.#hideHover();
137
+ this.#resetMarquee();
138
+ document.removeEventListener('pointerdown', this.#onPointerDown, true);
139
+ document.removeEventListener('pointermove', this.#onPointerMove, true);
140
+ document.removeEventListener('pointerup', this.#onPointerUp, true);
141
+ document.removeEventListener('pointerleave', this.#onPointerLeave, true);
142
+ document.removeEventListener('click', this.#onClick, true);
143
+ this.#setInsertionControlsVisible(false);
144
+ }
145
+
146
+ setInsertionZones(zones: SourceInsertionZone[]): void {
147
+ for (const { button } of this.#insertionControls.values()) button.remove();
148
+ this.#insertionControls.clear();
149
+ for (const zone of zones) {
150
+ const button = document.createElement('button');
151
+ button.type = 'button';
152
+ button.dataset.astroAiUi = 'insertion-zone';
153
+ button.textContent = zone.slot === undefined ? '+ Add to page' : `+ Add to ${zone.slot}`;
154
+ button.title = `Insert a native Astro element into ${zone.file}`;
155
+ Object.assign(button.style, {
156
+ position: 'fixed',
157
+ zIndex: String(EDITOR_LAYERS.insertionControl),
158
+ border: '1px solid #8b5cf6',
159
+ borderRadius: '999px',
160
+ background: '#181b23',
161
+ color: '#ede9fe',
162
+ padding: '5px 9px',
163
+ font: '600 11px/1.2 ui-sans-serif, system-ui, sans-serif',
164
+ cursor: 'pointer',
165
+ boxShadow: '0 4px 14px rgb(0 0 0 / .3)',
166
+ display: this.#active ? 'block' : 'none',
167
+ });
168
+ button.addEventListener('click', () => this.#insertAtZone(zone));
169
+ document.documentElement.append(button);
170
+ this.#insertionControls.set(zone.id, { zone, button });
171
+ }
172
+ this.#positionInsertionControls();
173
+ }
174
+
175
+ setSelection(context: SelectionContext): void {
176
+ const nodeId = context.selectedNode.nodeId;
177
+ const pending = this.#pending.get(nodeId);
178
+ this.#pending.delete(nodeId);
179
+ let existing = this.#selected.get(nodeId);
180
+ if (pending === undefined && existing === undefined) return;
181
+ if (pending?.mode === 'replace') {
182
+ this.#clearItems();
183
+ existing = undefined;
184
+ }
185
+ const target = pending?.element ?? existing?.element ?? findNodeById(nodeId);
186
+ if (target === undefined || !target.isConnected) {
187
+ this.#removeItem(nodeId);
188
+ this.#syncSelection();
189
+ return;
190
+ }
191
+ if (existing === undefined) {
192
+ const [highlight, label] = createHighlight(true);
193
+ document.documentElement.append(highlight);
194
+ existing = { element: target, context, highlight, label };
195
+ this.#selected.set(nodeId, existing);
196
+ } else {
197
+ existing.element = target;
198
+ existing.context = context;
199
+ }
200
+ this.#primaryNodeId = nodeId;
201
+ this.#updateItem(existing);
202
+ if (pending?.point !== undefined && this.#selected.size === 1) {
203
+ this.#actions.show(context, target, pending.point);
204
+ this.#syncSelection(false);
205
+ } else {
206
+ this.#syncSelection();
207
+ }
208
+ }
209
+
210
+ clearSelection(): void {
211
+ const hadSelection = this.#selected.size > 0 || this.#pending.size > 0;
212
+ this.#clearItems();
213
+ this.#pending.clear();
214
+ this.#syncSelection();
215
+ if (hadSelection) this.#callbacks.onClear();
216
+ }
217
+
218
+ rejectSelection(nodeId: string): void {
219
+ this.#pending.delete(nodeId);
220
+ if (this.#selected.has(nodeId)) this.#removeItem(nodeId);
221
+ this.#syncSelection();
222
+ }
223
+
224
+ destroy(): void {
225
+ this.disable();
226
+ this.#actions.destroy();
227
+ this.#hover.remove();
228
+ this.#marquee.remove();
229
+ for (const { button } of this.#insertionControls.values()) button.remove();
230
+ this.#insertionControls.clear();
231
+ }
232
+
233
+ readonly #onPointerDown = (event: PointerEvent): void => {
234
+ if (event.button !== 0 || isEditorEventTarget(event.target)) return;
235
+ this.#marqueeOrigin = { x: event.clientX, y: event.clientY, additive: event.shiftKey };
236
+ };
237
+
238
+ readonly #onPointerMove = (event: PointerEvent): void => {
239
+ const origin = this.#marqueeOrigin;
240
+ if (origin !== undefined && (event.buttons & 1) === 1) {
241
+ const distance = Math.hypot(event.clientX - origin.x, event.clientY - origin.y);
242
+ if (this.#marqueeActive || distance >= MARQUEE_THRESHOLD) {
243
+ this.#marqueeActive = true;
244
+ event.preventDefault();
245
+ event.stopImmediatePropagation();
246
+ this.#hideHover();
247
+ positionMarquee(this.#marquee, origin, { x: event.clientX, y: event.clientY });
248
+ this.#marquee.style.display = 'block';
249
+ return;
250
+ }
251
+ }
252
+ const source = findSourceElement(event.target);
253
+ if (source === undefined) {
254
+ this.#hideHover();
255
+ return;
256
+ }
257
+ positionElement(this.#hover, source);
258
+ this.#hover.style.display = 'block';
259
+ if (this.#hoveredElement === source) return;
260
+ this.#hoveredElement = source;
261
+ this.#hoverLabel.textContent = source.dataset.astroAiName ?? source.tagName.toLowerCase();
262
+ this.#hoverTooltip.style.display = 'none';
263
+ if (this.#tooltipTimer !== undefined) window.clearTimeout(this.#tooltipTimer);
264
+ const hint = source.dataset.astroAiSource;
265
+ if (hint !== undefined) {
266
+ this.#tooltipTimer = window.setTimeout(() => {
267
+ this.#hoverTooltip.textContent = hint;
268
+ this.#hoverTooltip.style.display = 'block';
269
+ }, 500);
270
+ }
271
+ };
272
+
273
+ readonly #onPointerUp = (event: PointerEvent): void => {
274
+ const origin = this.#marqueeOrigin;
275
+ if (origin === undefined) return;
276
+ if (!this.#marqueeActive) {
277
+ this.#marqueeOrigin = undefined;
278
+ return;
279
+ }
280
+ event.preventDefault();
281
+ event.stopImmediatePropagation();
282
+ const candidates = marqueeCandidates(normalizedRect(origin, { x: event.clientX, y: event.clientY }));
283
+ if (!origin.additive) this.#clearItems();
284
+ for (const target of candidates) {
285
+ const nodeId = target.dataset.astroAiId;
286
+ if (nodeId === undefined || this.#selected.has(nodeId) || this.#pending.has(nodeId)) continue;
287
+ this.#inspect(target, nodeId, 'add');
288
+ }
289
+ if (candidates.length === 0) this.#syncSelection();
290
+ this.#suppressClick = true;
291
+ window.setTimeout(() => { this.#suppressClick = false; }, 0);
292
+ this.#resetMarquee();
293
+ };
294
+
295
+ readonly #onPointerLeave = (): void => {
296
+ if (!this.#marqueeActive) this.#hideHover();
297
+ };
298
+
299
+ /**
300
+ * Delivers a real click to the page. Selection mode intercepts clicks to
301
+ * pick elements, so interception is lifted for the dispatch and restored
302
+ * afterwards — without this there is no way to open a menu or switch a tab
303
+ * to reach the state you actually want to edit.
304
+ */
305
+ clickThrough(target: HTMLElement): boolean {
306
+ if (!target.isConnected) return false;
307
+ const wasActive = this.#active;
308
+ if (wasActive) this.stop();
309
+ try {
310
+ target.click();
311
+ } finally {
312
+ // Restore on the next task so the page's own handlers and any resulting
313
+ // re-render settle before clicks are captured again.
314
+ if (wasActive) {
315
+ window.setTimeout(() => {
316
+ if (this.#enabled) this.start();
317
+ }, 0);
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+
323
+ readonly #onClick = (event: MouseEvent): void => {
324
+ if (this.#suppressClick) {
325
+ event.preventDefault();
326
+ event.stopImmediatePropagation();
327
+ return;
328
+ }
329
+ const target = findSourceElement(event.target);
330
+ const nodeId = target?.dataset.astroAiId;
331
+ if (target === undefined || nodeId === undefined) return;
332
+ event.preventDefault();
333
+ event.stopImmediatePropagation();
334
+ if (event.shiftKey && this.#selected.has(nodeId)) {
335
+ this.#removeItem(nodeId);
336
+ this.#syncSelection();
337
+ return;
338
+ }
339
+ this.#inspect(target, nodeId, event.shiftKey ? 'add' : 'replace');
340
+ };
341
+
342
+ readonly #onContextMenu = (event: MouseEvent): void => {
343
+ if (!this.#enabled || event.shiftKey) return;
344
+ const target = findSourceElement(event.target);
345
+ const nodeId = target?.dataset.astroAiId;
346
+ if (target === undefined || nodeId === undefined) return;
347
+ event.preventDefault();
348
+ event.stopImmediatePropagation();
349
+ this.#inspect(target, nodeId, 'replace', { x: event.clientX, y: event.clientY });
350
+ };
351
+
352
+ readonly #onViewportChange = (): void => {
353
+ if (this.#viewportFrame !== undefined) return;
354
+ this.#viewportFrame = window.requestAnimationFrame(() => {
355
+ this.#viewportFrame = undefined;
356
+ const rects: DOMRect[] = [];
357
+ for (const [nodeId, item] of this.#selected) {
358
+ if (!item.element.isConnected) this.#removeItem(nodeId);
359
+ else {
360
+ const rect = item.element.getBoundingClientRect();
361
+ rects.push(rect);
362
+ this.#updateItem(item, rect);
363
+ }
364
+ }
365
+ this.#actions.reposition();
366
+ this.#positionInsertionControls();
367
+ this.#emitAnchor(rects);
368
+ });
369
+ };
370
+
371
+ readonly #onMutations = (records: MutationRecord[]): void => {
372
+ if (records.every(({ target }) => isEditorUiTarget(target))) return;
373
+ this.#makeKeyboardReachable();
374
+ if (this.#selected.size === 0 || this.#mutationTimer !== undefined) return;
375
+ this.#mutationTimer = window.setTimeout(() => {
376
+ this.#mutationTimer = undefined;
377
+ for (const [nodeId, item] of this.#selected) {
378
+ // A node id identifies a source location, not a DOM node: one rendered
379
+ // in a loop repeats its id on every instance. Re-resolving by id would
380
+ // silently rebind the selection to the first repetition, so the element
381
+ // that is still on the page is kept as-is.
382
+ const relocated = item.element.isConnected
383
+ ? item.element
384
+ : findNodeById(nodeId);
385
+ if (relocated === undefined) {
386
+ this.#removeItem(nodeId);
387
+ continue;
388
+ }
389
+ item.element = relocated;
390
+ this.#updateItem(item);
391
+ this.#pending.set(nodeId, { element: relocated, mode: 'refresh' });
392
+ this.#callbacks.onInspect(nodeId);
393
+ }
394
+ this.#syncSelection();
395
+ }, 60);
396
+ };
397
+
398
+ #inspect(element: HTMLElement, nodeId: string, mode: SelectionMode, point?: AnchorPoint): void {
399
+ if (mode === 'replace') this.#pending.clear();
400
+ this.#pending.set(nodeId, { element, mode, ...(point === undefined ? {} : { point }) });
401
+ this.#callbacks.onInspect(nodeId);
402
+ }
403
+
404
+ #syncSelection(renderActions = true): void {
405
+ const items = [...this.#selected.values()];
406
+ if (items.length === 0) {
407
+ this.#primaryNodeId = undefined;
408
+ this.#actions.hide();
409
+ this.#callbacks.onSelectionChange([], []);
410
+ this.#callbacks.onSelectionAnchorChange();
411
+ return;
412
+ }
413
+ const primary = (this.#primaryNodeId === undefined ? undefined : this.#selected.get(this.#primaryNodeId)) ?? items.at(-1);
414
+ if (primary === undefined) return;
415
+ this.#primaryNodeId = primary.context.selectedNode.nodeId;
416
+ if (renderActions) {
417
+ if (items.length === 1) this.#actions.show(primary.context, primary.element);
418
+ else this.#actions.showMultiple(items.map(({ context }) => context), primary.element);
419
+ }
420
+ this.#callbacks.onSelectionChange(
421
+ items.map(({ context }) => context),
422
+ items.map(({ element }) => element),
423
+ );
424
+ this.#emitAnchor();
425
+ }
426
+
427
+ #emitAnchor(measured?: DOMRect[]): void {
428
+ const rects = measured ?? [...this.#selected.values()].filter(({ element }) => element.isConnected).map(({ element }) => element.getBoundingClientRect());
429
+ if (rects.length === 0) {
430
+ this.#callbacks.onSelectionAnchorChange();
431
+ return;
432
+ }
433
+ const left = Math.min(...rects.map(({ left }) => left));
434
+ const right = Math.max(...rects.map(({ right }) => right));
435
+ const top = Math.min(...rects.map(({ top }) => top));
436
+ const bottom = Math.max(...rects.map(({ bottom }) => bottom));
437
+ this.#callbacks.onSelectionAnchorChange(new DOMRect(left, top, right - left, bottom - top));
438
+ }
439
+
440
+ #updateItem(item: SelectedItem, rect?: DOMRect): void {
441
+ const source = item.context.selectedNode.source;
442
+ item.label.textContent = `${middleTruncatePath(source.file, 38)}:${source.start.line}`;
443
+ item.label.title = source.file;
444
+ item.highlight.style.display = 'block';
445
+ positionElement(item.highlight, item.element, rect);
446
+ }
447
+
448
+ #makeKeyboardReachable(): void {
449
+ for (const element of document.querySelectorAll<HTMLElement>(NODE_SELECTOR)) {
450
+ if (this.#tabIndexes.has(element)) continue;
451
+ this.#tabIndexes.set(element, element.getAttribute('tabindex'));
452
+ if (!element.hasAttribute('tabindex')) element.tabIndex = 0;
453
+ }
454
+ }
455
+
456
+ readonly #onFocusIn = (event: FocusEvent): void => {
457
+ if (!this.#active) return;
458
+ const source = findSourceElement(event.target);
459
+ if (source === undefined) return;
460
+ positionElement(this.#hover, source);
461
+ this.#hoverLabel.textContent = source.dataset.astroAiName ?? source.tagName.toLowerCase();
462
+ this.#hover.style.display = 'block';
463
+ };
464
+
465
+ readonly #onSelectionKeyDown = (event: KeyboardEvent): void => {
466
+ if (!this.#active || (event.key !== 'Enter' && event.key !== ' ')) return;
467
+ const target = findSourceElement(event.target);
468
+ const nodeId = target?.dataset.astroAiId;
469
+ if (target === undefined || nodeId === undefined) return;
470
+ event.preventDefault();
471
+ event.stopImmediatePropagation();
472
+ this.#inspect(target, nodeId, event.shiftKey ? 'add' : 'replace');
473
+ };
474
+
475
+ #removeItem(nodeId: string): void {
476
+ this.#selected.get(nodeId)?.highlight.remove();
477
+ this.#selected.delete(nodeId);
478
+ this.#pending.delete(nodeId);
479
+ if (this.#primaryNodeId === nodeId) this.#primaryNodeId = undefined;
480
+ }
481
+
482
+ #clearItems(): void {
483
+ for (const { highlight } of this.#selected.values()) highlight.remove();
484
+ this.#selected.clear();
485
+ this.#primaryNodeId = undefined;
486
+ }
487
+
488
+ #hideHover(): void {
489
+ if (this.#tooltipTimer !== undefined) window.clearTimeout(this.#tooltipTimer);
490
+ this.#tooltipTimer = undefined;
491
+ this.#hoveredElement = undefined;
492
+ this.#hover.style.display = 'none';
493
+ this.#hoverTooltip.style.display = 'none';
494
+ }
495
+
496
+ #resetMarquee(): void {
497
+ this.#marqueeOrigin = undefined;
498
+ this.#marqueeActive = false;
499
+ this.#marquee.style.display = 'none';
500
+ }
501
+
502
+ #setInsertionControlsVisible(visible: boolean): void {
503
+ for (const { button } of this.#insertionControls.values()) {
504
+ button.style.display = visible ? 'block' : 'none';
505
+ }
506
+ if (visible) this.#positionInsertionControls();
507
+ }
508
+
509
+ #positionInsertionControls(): void {
510
+ let rootIndex = 0;
511
+ for (const { zone, button } of this.#insertionControls.values()) {
512
+ if (zone.parentNodeId === undefined) {
513
+ Object.assign(button.style, { left: '16px', top: `${16 + rootIndex * 34}px` });
514
+ rootIndex += 1;
515
+ continue;
516
+ }
517
+ const parent = findNodeById(zone.parentNodeId);
518
+ if (parent === undefined) {
519
+ button.style.display = 'none';
520
+ continue;
521
+ }
522
+ const rect = parent.getBoundingClientRect();
523
+ Object.assign(button.style, {
524
+ display: this.#active ? 'block' : 'none',
525
+ left: `${Math.max(8, rect.left + 8)}px`,
526
+ top: `${Math.max(8, Math.min(window.innerHeight - 32, rect.bottom - 28))}px`,
527
+ });
528
+ }
529
+ }
530
+
531
+ #insertAtZone(zone: SourceInsertionZone): void {
532
+ const tag = window.prompt('Native element tag', 'section')?.trim();
533
+ if (tag === undefined || tag === '') return;
534
+ const text = window.prompt('Literal text', '') ?? undefined;
535
+ if (text === undefined) return;
536
+ this.#callbacks.onCommand({
537
+ kind: 'insert-literal-element',
538
+ file: zone.file,
539
+ ...(zone.parentNodeId === undefined ? {} : { parentNodeId: zone.parentNodeId }),
540
+ ...(zone.slot === undefined ? {} : { slot: zone.slot }),
541
+ tag,
542
+ text,
543
+ });
544
+ }
545
+ }
546
+
547
+ function createHighlight(selected: boolean): [HTMLDivElement, HTMLSpanElement, HTMLSpanElement] {
548
+ const highlight = document.createElement('div');
549
+ highlight.dataset.astroAiUi = selected ? 'selection' : 'hover';
550
+ Object.assign(highlight.style, {
551
+ position: 'fixed', zIndex: String(selected ? EDITOR_LAYERS.selectionOutline : EDITOR_LAYERS.hoverOutline), pointerEvents: 'none',
552
+ border: `2px ${selected ? 'solid' : 'dashed'} ${selected ? '#8b5cf6' : '#a78bfa'}`,
553
+ borderRadius: '4px', background: selected ? 'rgb(139 92 246 / 0.08)' : 'rgb(167 139 250 / 0.04)',
554
+ boxSizing: 'border-box', display: 'none',
555
+ });
556
+ const label = document.createElement('span');
557
+ Object.assign(label.style, {
558
+ position: 'absolute', bottom: '100%', left: '-2px', maxWidth: 'min(440px, 80vw)', overflow: 'hidden',
559
+ padding: '3px 7px', borderRadius: '4px 4px 0 0', background: selected ? '#6d28d9' : '#7c3aed',
560
+ color: 'white', font: '11px/1.4 ui-monospace, SFMono-Regular, monospace', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
561
+ });
562
+ const tooltip = document.createElement('span');
563
+ Object.assign(tooltip.style, {
564
+ position: 'absolute', left: '0', top: '6px', transform: 'translateY(100%)', background: '#111318',
565
+ border: '1px solid #454b59', borderRadius: '4px', color: '#e2e8f0', display: 'none',
566
+ font: '11px/1.4 ui-monospace, SFMono-Regular, monospace', padding: '4px 7px', whiteSpace: 'nowrap',
567
+ });
568
+ highlight.append(label, tooltip);
569
+ return [highlight, label, tooltip];
570
+ }
571
+
572
+ function createMarquee(): HTMLDivElement {
573
+ const marquee = document.createElement('div');
574
+ marquee.dataset.astroAiUi = 'marquee';
575
+ Object.assign(marquee.style, {
576
+ position: 'fixed', zIndex: String(EDITOR_LAYERS.marquee), pointerEvents: 'none', border: '1px solid #c4b5fd',
577
+ borderRadius: '3px', background: 'rgb(139 92 246 / 0.16)', boxShadow: '0 0 0 1px rgb(76 29 149 / .35) inset', display: 'none',
578
+ });
579
+ return marquee;
580
+ }
581
+
582
+ function positionElement(overlay: HTMLElement, target: HTMLElement, measured?: DOMRect): void {
583
+ const rect = measured ?? target.getBoundingClientRect();
584
+ Object.assign(overlay.style, { left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
585
+ }
586
+
587
+ function positionMarquee(element: HTMLElement, start: AnchorPoint, end: AnchorPoint): void {
588
+ const rect = normalizedRect(start, end);
589
+ Object.assign(element.style, { left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
590
+ }
591
+
592
+ export function normalizedRect(start: AnchorPoint, end: AnchorPoint): DOMRect {
593
+ const left = Math.min(start.x, end.x);
594
+ const top = Math.min(start.y, end.y);
595
+ return new DOMRect(left, top, Math.abs(end.x - start.x), Math.abs(end.y - start.y));
596
+ }
597
+
598
+ export function rectanglesIntersect(first: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>, second: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>): boolean {
599
+ return first.left <= second.right && first.right >= second.left && first.top <= second.bottom && first.bottom >= second.top;
600
+ }
601
+
602
+ function marqueeCandidates(selectionRect: DOMRect): HTMLElement[] {
603
+ const intersecting = [...document.querySelectorAll<HTMLElement>(NODE_SELECTOR)].filter((element) => {
604
+ const rect = element.getBoundingClientRect();
605
+ return rect.width > 0 && rect.height > 0 && rectanglesIntersect(selectionRect, rect);
606
+ });
607
+ const leafMost = intersecting.filter((element) => !intersecting.some((candidate) => candidate !== element && element.contains(candidate)));
608
+ const unique = new Map<string, HTMLElement>();
609
+ for (const element of leafMost) {
610
+ const nodeId = element.dataset.astroAiId;
611
+ if (nodeId !== undefined && !unique.has(nodeId)) unique.set(nodeId, element);
612
+ if (unique.size >= MAX_MARQUEE_SELECTION) break;
613
+ }
614
+ return [...unique.values()];
615
+ }
616
+
617
+ function findSourceElement(target: EventTarget | null): HTMLElement | undefined {
618
+ if (!(target instanceof Element)) return undefined;
619
+ if (target.closest('astro-dev-toolbar, [data-astro-ai-ui]') !== null) return undefined;
620
+ return target.closest<HTMLElement>(NODE_SELECTOR) ?? undefined;
621
+ }
622
+
623
+ function findNodeById(nodeId: string): HTMLElement | undefined {
624
+ return document.querySelector<HTMLElement>(`[data-astro-ai-id="${cssEscape(nodeId)}"]`) ?? undefined;
625
+ }
626
+
627
+ function isEditorEventTarget(target: EventTarget | null): boolean {
628
+ return target instanceof Element && target.closest('astro-dev-toolbar, [data-astro-ai-ui]') !== null;
629
+ }
630
+
631
+ function isEditorUiTarget(target: Node): boolean {
632
+ const element = target instanceof Element ? target : target.parentElement;
633
+ return element !== null && element.closest('[data-astro-ai-ui]') !== null;
634
+ }
635
+
636
+ function cssEscape(value: string): string {
637
+ return globalThis.CSS?.escape(value) ?? value.replaceAll('"', '\\"');
638
+ }