@dryui/feedback 0.0.2

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 (79) hide show
  1. package/dist/components/annotation-marker.svelte +163 -0
  2. package/dist/components/annotation-marker.svelte.d.ts +11 -0
  3. package/dist/components/annotation-popup.svelte +669 -0
  4. package/dist/components/annotation-popup.svelte.d.ts +42 -0
  5. package/dist/components/highlight-overlay.svelte +48 -0
  6. package/dist/components/highlight-overlay.svelte.d.ts +8 -0
  7. package/dist/components/settings-panel.svelte +446 -0
  8. package/dist/components/settings-panel.svelte.d.ts +24 -0
  9. package/dist/components/toolbar.svelte +1111 -0
  10. package/dist/components/toolbar.svelte.d.ts +46 -0
  11. package/dist/constants.d.ts +9 -0
  12. package/dist/constants.js +37 -0
  13. package/dist/feedback.svelte +2879 -0
  14. package/dist/feedback.svelte.d.ts +4 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.js +7 -0
  17. package/dist/layout-mode/catalog.d.ts +16 -0
  18. package/dist/layout-mode/catalog.js +81 -0
  19. package/dist/layout-mode/component-actions.svelte +84 -0
  20. package/dist/layout-mode/component-actions.svelte.d.ts +18 -0
  21. package/dist/layout-mode/component-picker.svelte +73 -0
  22. package/dist/layout-mode/component-picker.svelte.d.ts +10 -0
  23. package/dist/layout-mode/design-mode.svelte +1115 -0
  24. package/dist/layout-mode/design-mode.svelte.d.ts +24 -0
  25. package/dist/layout-mode/design-palette.svelte +396 -0
  26. package/dist/layout-mode/design-palette.svelte.d.ts +20 -0
  27. package/dist/layout-mode/element-heuristics.d.ts +5 -0
  28. package/dist/layout-mode/element-heuristics.js +51 -0
  29. package/dist/layout-mode/freeze.d.ts +6 -0
  30. package/dist/layout-mode/freeze.js +163 -0
  31. package/dist/layout-mode/generated-library.d.ts +940 -0
  32. package/dist/layout-mode/generated-library.js +1445 -0
  33. package/dist/layout-mode/geometry.d.ts +38 -0
  34. package/dist/layout-mode/geometry.js +133 -0
  35. package/dist/layout-mode/history.d.ts +10 -0
  36. package/dist/layout-mode/history.js +45 -0
  37. package/dist/layout-mode/index.d.ts +23 -0
  38. package/dist/layout-mode/index.js +18 -0
  39. package/dist/layout-mode/live-mount.d.ts +20 -0
  40. package/dist/layout-mode/live-mount.js +70 -0
  41. package/dist/layout-mode/output.d.ts +26 -0
  42. package/dist/layout-mode/output.js +550 -0
  43. package/dist/layout-mode/placement-skeleton.d.ts +9 -0
  44. package/dist/layout-mode/placement-skeleton.js +535 -0
  45. package/dist/layout-mode/rearrange-overlay.svelte +1293 -0
  46. package/dist/layout-mode/rearrange-overlay.svelte.d.ts +18 -0
  47. package/dist/layout-mode/responsive-bar.svelte +39 -0
  48. package/dist/layout-mode/responsive-bar.svelte.d.ts +8 -0
  49. package/dist/layout-mode/route-creator.svelte +70 -0
  50. package/dist/layout-mode/route-creator.svelte.d.ts +8 -0
  51. package/dist/layout-mode/section-detection.d.ts +6 -0
  52. package/dist/layout-mode/section-detection.js +214 -0
  53. package/dist/layout-mode/spatial.d.ts +42 -0
  54. package/dist/layout-mode/spatial.js +156 -0
  55. package/dist/layout-mode/types.d.ts +144 -0
  56. package/dist/layout-mode/types.js +84 -0
  57. package/dist/types.d.ts +157 -0
  58. package/dist/types.js +1 -0
  59. package/dist/utils/dryui-detection.d.ts +1 -0
  60. package/dist/utils/dryui-detection.js +219 -0
  61. package/dist/utils/element-id.d.ts +12 -0
  62. package/dist/utils/element-id.js +333 -0
  63. package/dist/utils/freeze.d.ts +7 -0
  64. package/dist/utils/freeze.js +168 -0
  65. package/dist/utils/output.d.ts +15 -0
  66. package/dist/utils/output.js +245 -0
  67. package/dist/utils/selection.d.ts +22 -0
  68. package/dist/utils/selection.js +58 -0
  69. package/dist/utils/shadow-dom.d.ts +4 -0
  70. package/dist/utils/shadow-dom.js +39 -0
  71. package/dist/utils/storage.d.ts +30 -0
  72. package/dist/utils/storage.js +206 -0
  73. package/dist/utils/svelte-detection.d.ts +8 -0
  74. package/dist/utils/svelte-detection.js +86 -0
  75. package/dist/utils/svelte-meta.d.ts +6 -0
  76. package/dist/utils/svelte-meta.js +69 -0
  77. package/dist/utils/sync.d.ts +18 -0
  78. package/dist/utils/sync.js +62 -0
  79. package/package.json +65 -0
@@ -0,0 +1,1293 @@
1
+ <script lang="ts">
2
+ import { Badge, Button, Stack, Text } from '@dryui/ui';
3
+ import AnnotationPopup from '../components/annotation-popup.svelte';
4
+ import {
5
+ computeSectionSnap,
6
+ createRectFromPoint,
7
+ isMeaningfulDrag,
8
+ MIN_CAPTURE_SIZE,
9
+ MIN_SIZE,
10
+ } from './geometry.js';
11
+ import { originalSetTimeout } from './freeze.js';
12
+ import { captureElement, detectPageSections } from './section-detection.js';
13
+ import type { DetectedSection, RearrangeState, Rect } from './types.js';
14
+
15
+ interface Props {
16
+ rearrangeState: RearrangeState;
17
+ onChange: (state: RearrangeState) => void;
18
+ blankCanvas?: boolean;
19
+ extraSnapRects?: Rect[];
20
+ deselectSignal?: number;
21
+ clearSignal?: number;
22
+ exiting?: boolean;
23
+ class?: string;
24
+ onSelectionChange?: (selectedIds: Set<string>, isShift: boolean) => void;
25
+ onInteractionChange?: (active: boolean) => void;
26
+ onDragMove?: (dx: number, dy: number) => void;
27
+ onDragEnd?: (dx: number, dy: number, committed: boolean) => void;
28
+ }
29
+
30
+ type Point = { x: number; y: number };
31
+ type HandleDir = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
32
+ type PreviewRects = Record<string, Rect>;
33
+
34
+ const CORNER_HANDLES: HandleDir[] = ['nw', 'ne', 'se', 'sw'];
35
+ const EDGE_HANDLES: HandleDir[] = ['n', 'e', 's', 'w'];
36
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta', 'br', 'hr']);
37
+ const MAX_SIZE_DELTA = 200;
38
+
39
+ let {
40
+ rearrangeState,
41
+ onChange,
42
+ blankCanvas = false,
43
+ extraSnapRects,
44
+ deselectSignal,
45
+ clearSignal,
46
+ exiting = false,
47
+ class: className,
48
+ onSelectionChange,
49
+ onInteractionChange,
50
+ onDragMove,
51
+ onDragEnd,
52
+ }: Props = $props();
53
+
54
+ let selectedIds = $state<string[]>([]);
55
+ let drawBox = $state<Rect | null>(null);
56
+ let previewRects = $state<PreviewRects>({});
57
+ let guides = $state<{ axis: 'x' | 'y'; pos: number }[]>([]);
58
+ let hoverRect = $state<Rect | null>(null);
59
+ let hoverLabel = $state<string | null>(null);
60
+ let sizeIndicator = $state<{ x: number; y: number; text: string } | null>(null);
61
+ let noteTargetId = $state<string | null>(null);
62
+ let scrollY = $state(typeof window !== 'undefined' ? window.scrollY : 0);
63
+ let viewportWidth = $state(typeof window !== 'undefined' ? window.innerWidth : 1440);
64
+ let pointerStart = $state<Point | null>(null);
65
+ let domRevision = $state(0);
66
+ let hasMeasuredInitialDom = false;
67
+ let lastDeselectSignal: number | undefined = undefined;
68
+ let lastClearSignal: number | undefined = undefined;
69
+ let exitingIds = $state<string[]>([]);
70
+ let exitingConnectors = $state<Record<string, { orig: Rect; target: Rect; isFixed?: boolean }>>({});
71
+ const previousChangedIds = new Set<string>();
72
+ const lastChangedRects = new Map<string, { currentRect: Rect; originalRect: Rect; isFixed?: boolean }>();
73
+
74
+ function isSelected(id: string): boolean {
75
+ return selectedIds.includes(id);
76
+ }
77
+
78
+ function syncViewport() {
79
+ scrollY = window.scrollY;
80
+ viewportWidth = window.innerWidth;
81
+ }
82
+
83
+ function rectChanged(a: Rect, b: Rect): boolean {
84
+ return (
85
+ Math.abs(a.x - b.x) > 0.5 ||
86
+ Math.abs(a.y - b.y) > 0.5 ||
87
+ Math.abs(a.width - b.width) > 0.5 ||
88
+ Math.abs(a.height - b.height) > 0.5
89
+ );
90
+ }
91
+
92
+ function isMoved(section: DetectedSection, rect: Rect = getRenderedRect(section)): boolean {
93
+ return (
94
+ Math.abs(section.originalRect.x - rect.x) > 0.5 ||
95
+ Math.abs(section.originalRect.y - rect.y) > 0.5
96
+ );
97
+ }
98
+
99
+ function isResized(section: DetectedSection, rect: Rect = getRenderedRect(section)): boolean {
100
+ return (
101
+ Math.abs(section.originalRect.width - rect.width) > 0.5 ||
102
+ Math.abs(section.originalRect.height - rect.height) > 0.5
103
+ );
104
+ }
105
+
106
+ function hasChanged(section: DetectedSection, rect: Rect = getRenderedRect(section)): boolean {
107
+ return isMoved(section, rect) || isResized(section, rect);
108
+ }
109
+
110
+ function getRenderedRect(section: DetectedSection): Rect {
111
+ return previewRects[section.id] ?? section.currentRect;
112
+ }
113
+
114
+ function toViewportRect(section: DetectedSection, rect: Rect = getRenderedRect(section)): Rect {
115
+ return {
116
+ x: rect.x,
117
+ y: section.isFixed ? rect.y : rect.y - scrollY,
118
+ width: rect.width,
119
+ height: rect.height,
120
+ };
121
+ }
122
+
123
+ function setSelection(ids: string[], shiftKey: boolean = false) {
124
+ selectedIds = ids;
125
+ onSelectionChange?.(new Set(ids), shiftKey);
126
+ }
127
+
128
+ function applySelection(id: string, shiftKey: boolean): string[] {
129
+ if (!shiftKey) {
130
+ return [id];
131
+ }
132
+
133
+ return isSelected(id) ? selectedIds.filter((selected) => selected !== id) : [...selectedIds, id];
134
+ }
135
+
136
+ function handleCursor(handle: HandleDir): string {
137
+ return handle === 'n' || handle === 's'
138
+ ? 'ns-resize'
139
+ : handle === 'e' || handle === 'w'
140
+ ? 'ew-resize'
141
+ : handle === 'ne' || handle === 'sw'
142
+ ? 'nesw-resize'
143
+ : 'nwse-resize';
144
+ }
145
+
146
+ function cornerHandleOffset(handle: HandleDir): string {
147
+ return `${handle.includes('n') ? 'top: -4px;' : 'bottom: -4px;'} ${handle.includes('w') ? 'left: -4px;' : 'right: -4px;'}`;
148
+ }
149
+
150
+ function edgeHandleInset(handle: HandleDir): string {
151
+ switch (handle) {
152
+ case 'n':
153
+ return 'left: 12px; right: 12px; top: -6px; height: 12px;';
154
+ case 's':
155
+ return 'left: 12px; right: 12px; bottom: -6px; height: 12px;';
156
+ case 'e':
157
+ return 'top: 12px; bottom: 12px; right: -6px; width: 12px;';
158
+ case 'w':
159
+ return 'top: 12px; bottom: 12px; left: -6px; width: 12px;';
160
+ default:
161
+ return '';
162
+ }
163
+ }
164
+
165
+ function suggestedChangeLabel(section: DetectedSection, rect: Rect): string | null {
166
+ const moved = isMoved(section, rect);
167
+ const resized = isResized(section, rect);
168
+
169
+ if (moved && resized) return 'Suggested Move & Resize';
170
+ if (resized) return 'Suggested Resize';
171
+ if (moved) return 'Suggested Move';
172
+ return null;
173
+ }
174
+
175
+ function deriveOriginalOrder(nextSections: DetectedSection[]): string[] {
176
+ const ids = new Set(nextSections.map((section) => section.id));
177
+ const order = rearrangeState.originalOrder.filter((id) => ids.has(id));
178
+
179
+ for (const section of nextSections) {
180
+ if (!order.includes(section.id)) {
181
+ order.push(section.id);
182
+ }
183
+ }
184
+
185
+ return order;
186
+ }
187
+
188
+ function updateState(nextSections: DetectedSection[], nextOrder: string[] = deriveOriginalOrder(nextSections)) {
189
+ onChange({
190
+ ...rearrangeState,
191
+ sections: nextSections,
192
+ originalOrder: nextOrder,
193
+ });
194
+ }
195
+
196
+ function removeSections(ids: string[]) {
197
+ const removed = new Set(ids);
198
+ exitingIds = Array.from(new Set([...exitingIds, ...ids]));
199
+ setSelection([], false);
200
+ originalSetTimeout(() => {
201
+ exitingIds = exitingIds.filter((id) => !removed.has(id));
202
+ updateState(
203
+ rearrangeState.sections.filter((section) => !removed.has(section.id)),
204
+ rearrangeState.originalOrder.filter((id) => !removed.has(id)),
205
+ );
206
+ }, 180);
207
+ }
208
+
209
+ function captureSections() {
210
+ const sections = detectPageSections();
211
+ updateState(
212
+ sections,
213
+ sections.map((section) => section.id),
214
+ );
215
+ }
216
+
217
+ function clearTransientState() {
218
+ drawBox = null;
219
+ previewRects = {};
220
+ guides = [];
221
+ hoverRect = null;
222
+ hoverLabel = null;
223
+ sizeIndicator = null;
224
+ pointerStart = null;
225
+ onInteractionChange?.(false);
226
+ }
227
+
228
+ function getDomSection(section: DetectedSection): Element | null {
229
+ if (blankCanvas || !section.selector) return null;
230
+
231
+ try {
232
+ return document.querySelector(section.selector);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function isVisibleSection(section: DetectedSection): boolean {
239
+ if (blankCanvas) return true;
240
+ if (selectedIds.includes(section.id) || noteTargetId === section.id) return true;
241
+
242
+ const element = getDomSection(section);
243
+ if (!element) return false;
244
+
245
+ const rect = element.getBoundingClientRect();
246
+ const expected = section.originalRect;
247
+ const sizeDiff = Math.abs(rect.width - expected.width) + Math.abs(rect.height - expected.height);
248
+
249
+ return sizeDiff < MAX_SIZE_DELTA;
250
+ }
251
+
252
+ let visibleSections = $derived.by(() => {
253
+ domRevision;
254
+ return rearrangeState.sections.filter((section) => isVisibleSection(section));
255
+ });
256
+
257
+ let unchangedSections = $derived(visibleSections.filter((section) => !hasChanged(section)));
258
+ let changedSections = $derived(visibleSections.filter((section) => hasChanged(section)));
259
+ let connectorSections = $derived.by(() => {
260
+ const next: Array<{
261
+ id: string;
262
+ orig: Rect;
263
+ target: Rect;
264
+ isFixed?: boolean;
265
+ exiting?: boolean;
266
+ }> = changedSections.map((section) => ({
267
+ id: section.id,
268
+ orig: section.originalRect,
269
+ target: getRenderedRect(section),
270
+ isFixed: section.isFixed,
271
+ }));
272
+
273
+ for (const [id, connector] of Object.entries(exitingConnectors)) {
274
+ if (next.some((entry) => entry.id === id)) continue;
275
+ next.push({
276
+ id,
277
+ orig: connector.orig,
278
+ target: connector.target,
279
+ isFixed: connector.isFixed,
280
+ exiting: true,
281
+ });
282
+ }
283
+
284
+ return next;
285
+ });
286
+
287
+ function findCapturedSectionByElement(el: Element): DetectedSection | null {
288
+ for (const section of rearrangeState.sections) {
289
+ const matched = getDomSection(section);
290
+ if (matched && (matched === el || matched.contains(el) || el.contains(matched))) {
291
+ return section;
292
+ }
293
+ }
294
+
295
+ return null;
296
+ }
297
+
298
+ function pickTarget(el: Element | null): HTMLElement | null {
299
+ let current = el instanceof HTMLElement ? el : null;
300
+
301
+ while (current && current !== document.body && current !== document.documentElement) {
302
+ if (current.closest('[data-dryui-feedback]')) return null;
303
+ if (current.closest('[data-rearrange-mode-controls]')) return null;
304
+ if (current.closest('[data-rearrange-section]')) return null;
305
+
306
+ const tag = current.tagName.toLowerCase();
307
+ if (SKIP_TAGS.has(tag)) {
308
+ current = current.parentElement;
309
+ continue;
310
+ }
311
+
312
+ const rect = current.getBoundingClientRect();
313
+ if (rect.width >= MIN_CAPTURE_SIZE && rect.height >= MIN_CAPTURE_SIZE) {
314
+ return current;
315
+ }
316
+
317
+ current = current.parentElement;
318
+ }
319
+
320
+ return null;
321
+ }
322
+
323
+ function captureSectionFromRect(rect: Rect): DetectedSection {
324
+ return {
325
+ id: `rs-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
326
+ label: blankCanvas ? 'Wireframe section' : 'Selected section',
327
+ tagName: 'div',
328
+ selector: `section-${Math.random().toString(36).slice(2, 7)}`,
329
+ role: null,
330
+ className: null,
331
+ textSnippet: null,
332
+ originalRect: {
333
+ x: rect.x,
334
+ y: rect.y + scrollY,
335
+ width: rect.width,
336
+ height: rect.height,
337
+ },
338
+ currentRect: {
339
+ x: rect.x,
340
+ y: rect.y + scrollY,
341
+ width: rect.width,
342
+ height: rect.height,
343
+ },
344
+ originalIndex: rearrangeState.sections.length,
345
+ isFixed: false,
346
+ };
347
+ }
348
+
349
+ function openNoteEditor(id: string) {
350
+ noteTargetId = id;
351
+ }
352
+
353
+ function saveNote(note: string) {
354
+ if (!noteTargetId) return;
355
+
356
+ updateState(
357
+ rearrangeState.sections.map((section) =>
358
+ section.id === noteTargetId ? { ...section, note: note.trim() || undefined } : section,
359
+ ),
360
+ );
361
+ noteTargetId = null;
362
+ }
363
+
364
+ function cancelNote() {
365
+ noteTargetId = null;
366
+ }
367
+
368
+ function clearNote() {
369
+ if (!noteTargetId) return;
370
+
371
+ updateState(
372
+ rearrangeState.sections.map((section) => (section.id === noteTargetId ? { ...section, note: undefined } : section)),
373
+ );
374
+ noteTargetId = null;
375
+ }
376
+
377
+ function getNotePopupPosition(section: DetectedSection): { x: number; y: number } {
378
+ const rect = toViewportRect(section);
379
+ const popupWidth = 360;
380
+ const popupHeight = 240;
381
+ const centerX = rect.x + rect.width / 2;
382
+ const fitsAbove = rect.y > popupHeight + 24;
383
+ const fitsBelow = rect.y + rect.height + popupHeight + 24 < window.innerHeight;
384
+ const x = Math.max(12, Math.min(viewportWidth - popupWidth - 12, centerX - popupWidth / 2));
385
+
386
+ if (fitsAbove) {
387
+ return { x, y: Math.max(16, rect.y - popupHeight - 12) };
388
+ }
389
+
390
+ if (fitsBelow) {
391
+ return { x, y: rect.y + rect.height + 12 };
392
+ }
393
+
394
+ return {
395
+ x,
396
+ y: Math.max(16, Math.min(window.innerHeight - popupHeight - 16, window.innerHeight / 2 - popupHeight / 2)),
397
+ };
398
+ }
399
+
400
+ function startMove(event: MouseEvent, anchorId: string, ids: string[], sections: DetectedSection[] = rearrangeState.sections) {
401
+ if (event.button !== 0) return;
402
+
403
+ const baseRects = new Map(
404
+ sections
405
+ .filter((section) => ids.includes(section.id))
406
+ .map((section) => [section.id, { ...section.currentRect }] as const),
407
+ );
408
+ const anchorRect = baseRects.get(anchorId);
409
+ if (!anchorRect) return;
410
+
411
+ pointerStart = { x: event.clientX, y: event.clientY };
412
+ onInteractionChange?.(true);
413
+ let moved = false;
414
+ let lastDx = 0;
415
+ let lastDy = 0;
416
+
417
+ const handleMove = (moveEvent: MouseEvent) => {
418
+ if (!pointerStart) return;
419
+
420
+ const deltaX = moveEvent.clientX - pointerStart.x;
421
+ const deltaY = moveEvent.clientY - pointerStart.y;
422
+ if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
423
+ moved = true;
424
+ }
425
+ const nextAnchor = {
426
+ x: anchorRect.x + deltaX,
427
+ y: anchorRect.y + deltaY,
428
+ width: anchorRect.width,
429
+ height: anchorRect.height,
430
+ };
431
+ const snap = computeSectionSnap(nextAnchor, sections, new Set(ids), undefined, extraSnapRects);
432
+ const appliedDx = deltaX + snap.dx;
433
+ const appliedDy = deltaY + snap.dy;
434
+ lastDx = appliedDx;
435
+ lastDy = appliedDy;
436
+
437
+ previewRects = Object.fromEntries(
438
+ ids.map((id) => {
439
+ const baseRect = baseRects.get(id)!;
440
+ return [
441
+ id,
442
+ {
443
+ x: Math.max(0, baseRect.x + appliedDx),
444
+ y: Math.max(0, baseRect.y + appliedDy),
445
+ width: baseRect.width,
446
+ height: baseRect.height,
447
+ },
448
+ ];
449
+ }),
450
+ );
451
+ guides = snap.guides;
452
+ onDragMove?.(appliedDx, appliedDy);
453
+ };
454
+
455
+ const handleUp = () => {
456
+ window.removeEventListener('mousemove', handleMove);
457
+ window.removeEventListener('mouseup', handleUp);
458
+
459
+ if (moved && Object.keys(previewRects).length > 0) {
460
+ updateState(
461
+ sections.map((section) =>
462
+ ids.includes(section.id)
463
+ ? {
464
+ ...section,
465
+ currentRect: previewRects[section.id] ?? section.currentRect,
466
+ }
467
+ : section,
468
+ ),
469
+ );
470
+ }
471
+
472
+ onDragEnd?.(lastDx, lastDy, moved);
473
+ clearTransientState();
474
+ };
475
+
476
+ window.addEventListener('mousemove', handleMove);
477
+ window.addEventListener('mouseup', handleUp);
478
+ }
479
+
480
+ function resizeRect(baseRect: Rect, dir: HandleDir, dx: number, dy: number, keepAspectRatio: boolean = false): Rect {
481
+ let next = { ...baseRect };
482
+ const aspectRatio = baseRect.height > 0 ? baseRect.width / baseRect.height : 1;
483
+
484
+ if (dir.includes('e')) {
485
+ next.width = Math.max(MIN_SIZE, baseRect.width + dx);
486
+ }
487
+ if (dir.includes('s')) {
488
+ next.height = Math.max(MIN_SIZE, baseRect.height + dy);
489
+ }
490
+ if (dir.includes('w')) {
491
+ const width = Math.max(MIN_SIZE, baseRect.width - dx);
492
+ next.x = baseRect.x + (baseRect.width - width);
493
+ next.width = width;
494
+ }
495
+ if (dir.includes('n')) {
496
+ const height = Math.max(MIN_SIZE, baseRect.height - dy);
497
+ next.y = baseRect.y + (baseRect.height - height);
498
+ next.height = height;
499
+ }
500
+
501
+ if (keepAspectRatio) {
502
+ const isCorner = dir.length === 2;
503
+ if (isCorner) {
504
+ const widthDelta = Math.abs(next.width - baseRect.width);
505
+ const heightDelta = Math.abs(next.height - baseRect.height);
506
+ if (widthDelta > heightDelta) {
507
+ next.height = Math.max(MIN_SIZE, next.width / aspectRatio);
508
+ } else {
509
+ next.width = Math.max(MIN_SIZE, next.height * aspectRatio);
510
+ }
511
+ } else if (dir === 'e' || dir === 'w') {
512
+ next.height = Math.max(MIN_SIZE, next.width / aspectRatio);
513
+ } else {
514
+ next.width = Math.max(MIN_SIZE, next.height * aspectRatio);
515
+ }
516
+
517
+ if (dir.includes('w')) {
518
+ next.x = baseRect.x + (baseRect.width - next.width);
519
+ }
520
+ if (dir.includes('n')) {
521
+ next.y = baseRect.y + (baseRect.height - next.height);
522
+ }
523
+ }
524
+
525
+ return next;
526
+ }
527
+
528
+ function startResize(event: MouseEvent, sectionId: string, dir: HandleDir) {
529
+ if (event.button !== 0) return;
530
+ event.preventDefault();
531
+ event.stopPropagation();
532
+
533
+ const section = rearrangeState.sections.find((candidate) => candidate.id === sectionId);
534
+ if (!section) return;
535
+
536
+ pointerStart = { x: event.clientX, y: event.clientY };
537
+ onInteractionChange?.(true);
538
+ setSelection([sectionId], false);
539
+
540
+ const activeEdges = {
541
+ left: dir.includes('w'),
542
+ right: dir.includes('e'),
543
+ top: dir.includes('n'),
544
+ bottom: dir.includes('s'),
545
+ };
546
+
547
+ const handleMove = (moveEvent: MouseEvent) => {
548
+ if (!pointerStart) return;
549
+
550
+ const nextRect = resizeRect(
551
+ section.currentRect,
552
+ dir,
553
+ moveEvent.clientX - pointerStart.x,
554
+ moveEvent.clientY - pointerStart.y,
555
+ moveEvent.shiftKey,
556
+ );
557
+ const snap = computeSectionSnap(nextRect, rearrangeState.sections, new Set([sectionId]), activeEdges, extraSnapRects);
558
+ const snappedRect = {
559
+ x: Math.max(0, nextRect.x + snap.dx),
560
+ y: Math.max(0, nextRect.y + snap.dy),
561
+ width: nextRect.width,
562
+ height: nextRect.height,
563
+ };
564
+
565
+ previewRects = { [sectionId]: snappedRect };
566
+ guides = snap.guides;
567
+ sizeIndicator = {
568
+ x: moveEvent.clientX + 12,
569
+ y: moveEvent.clientY + 12,
570
+ text: `${Math.round(snappedRect.width)} × ${Math.round(snappedRect.height)}`,
571
+ };
572
+ };
573
+
574
+ const handleUp = () => {
575
+ window.removeEventListener('mousemove', handleMove);
576
+ window.removeEventListener('mouseup', handleUp);
577
+
578
+ const nextRect = previewRects[sectionId];
579
+ if (nextRect) {
580
+ updateState(
581
+ rearrangeState.sections.map((candidate) =>
582
+ candidate.id === sectionId ? { ...candidate, currentRect: nextRect } : candidate,
583
+ ),
584
+ );
585
+ }
586
+
587
+ clearTransientState();
588
+ };
589
+
590
+ window.addEventListener('mousemove', handleMove);
591
+ window.addEventListener('mouseup', handleUp);
592
+ }
593
+
594
+ function beginDraw(event: PointerEvent) {
595
+ if (event.button !== 0) return;
596
+
597
+ pointerStart = { x: event.clientX, y: event.clientY };
598
+ onInteractionChange?.(true);
599
+
600
+ const handleMove = (moveEvent: PointerEvent) => {
601
+ if (!pointerStart) return;
602
+
603
+ drawBox = createRectFromPoint(pointerStart, { x: moveEvent.clientX, y: moveEvent.clientY });
604
+ sizeIndicator = {
605
+ x: moveEvent.clientX + 12,
606
+ y: moveEvent.clientY + 12,
607
+ text: `${Math.round(drawBox.width)} x ${Math.round(drawBox.height)}`,
608
+ };
609
+ };
610
+
611
+ const handleUp = () => {
612
+ window.removeEventListener('pointermove', handleMove);
613
+ window.removeEventListener('pointerup', handleUp);
614
+
615
+ if (drawBox && isMeaningfulDrag(drawBox, MIN_SIZE)) {
616
+ const section = captureSectionFromRect(drawBox);
617
+ updateState([...rearrangeState.sections, section]);
618
+ setSelection([section.id], false);
619
+ }
620
+
621
+ clearTransientState();
622
+ };
623
+
624
+ window.addEventListener('pointermove', handleMove);
625
+ window.addEventListener('pointerup', handleUp);
626
+ }
627
+
628
+ function handleDrawLayerPointerDown(event: PointerEvent) {
629
+ if (event.target instanceof HTMLElement && event.target.closest('[data-rearrange-section]')) return;
630
+ beginDraw(event);
631
+ }
632
+
633
+ function handleDocumentMouseMove(event: MouseEvent) {
634
+ if (blankCanvas || pointerStart) return;
635
+
636
+ const target = pickTarget(event.target instanceof Element ? event.target : null);
637
+ if (!target) {
638
+ hoverRect = null;
639
+ hoverLabel = null;
640
+ return;
641
+ }
642
+
643
+ if (findCapturedSectionByElement(target)) {
644
+ hoverRect = null;
645
+ hoverLabel = null;
646
+ return;
647
+ }
648
+
649
+ const rect = target.getBoundingClientRect();
650
+ hoverRect = {
651
+ x: rect.x,
652
+ y: rect.y,
653
+ width: rect.width,
654
+ height: rect.height,
655
+ };
656
+ hoverLabel = target.getAttribute('aria-label') || target.tagName.toLowerCase();
657
+ }
658
+
659
+ function handleDocumentMouseDown(event: MouseEvent) {
660
+ if (blankCanvas || event.button !== 0) return;
661
+ if (event.target instanceof Element && event.target.closest('[data-dryui-feedback]')) return;
662
+ if (event.target instanceof Element && event.target.closest('[data-rearrange-mode-controls]')) return;
663
+ if (event.target instanceof Element && event.target.closest('[data-rearrange-section]')) return;
664
+
665
+ const target = pickTarget(event.target instanceof Element ? event.target : null);
666
+ if (!target) {
667
+ if (!(event.shiftKey || event.metaKey || event.ctrlKey)) {
668
+ setSelection([], false);
669
+ }
670
+ return;
671
+ }
672
+
673
+ event.preventDefault();
674
+ event.stopPropagation();
675
+
676
+ const existing = findCapturedSectionByElement(target);
677
+ if (existing) {
678
+ const nextSelection = applySelection(existing.id, event.shiftKey || event.metaKey || event.ctrlKey);
679
+ setSelection(nextSelection, event.shiftKey || event.metaKey || event.ctrlKey);
680
+ return;
681
+ }
682
+
683
+ const captured = captureElement(target);
684
+ const nextSections = [...rearrangeState.sections, captured];
685
+ const nextOrder = [...deriveOriginalOrder(rearrangeState.sections), captured.id];
686
+ updateState(nextSections, nextOrder);
687
+ setSelection([captured.id], false);
688
+ hoverRect = null;
689
+ hoverLabel = null;
690
+ startMove(event, captured.id, [captured.id], nextSections);
691
+ }
692
+
693
+ function handleSectionMouseDown(event: MouseEvent, sectionId: string) {
694
+ event.preventDefault();
695
+ event.stopPropagation();
696
+
697
+ const shiftKey = event.shiftKey || event.metaKey || event.ctrlKey;
698
+ const nextSelection = applySelection(sectionId, shiftKey);
699
+ const movingIds = isSelected(sectionId) && !shiftKey ? [...selectedIds] : nextSelection;
700
+
701
+ setSelection(nextSelection, shiftKey);
702
+ startMove(event, sectionId, movingIds);
703
+ }
704
+
705
+ function handleKeyDown(event: KeyboardEvent) {
706
+ if (event.key === 'Escape') {
707
+ clearTransientState();
708
+ setSelection([], false);
709
+ cancelNote();
710
+ return;
711
+ }
712
+
713
+ if (selectedIds.length === 0) return;
714
+
715
+ if (event.key === 'Delete' || event.key === 'Backspace') {
716
+ event.preventDefault();
717
+ removeSections(selectedIds);
718
+ return;
719
+ }
720
+
721
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
722
+
723
+ event.preventDefault();
724
+ const step = event.shiftKey ? 20 : 1;
725
+ const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
726
+ const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
727
+
728
+ updateState(
729
+ rearrangeState.sections.map((section) =>
730
+ selectedIds.includes(section.id)
731
+ ? {
732
+ ...section,
733
+ currentRect: {
734
+ ...section.currentRect,
735
+ x: Math.max(0, section.currentRect.x + dx),
736
+ y: Math.max(0, section.currentRect.y + dy),
737
+ },
738
+ }
739
+ : section,
740
+ ),
741
+ );
742
+ }
743
+
744
+ function clearAll() {
745
+ setSelection([], false);
746
+ updateState([], []);
747
+ }
748
+
749
+ $effect(() => {
750
+ if (deselectSignal === lastDeselectSignal) return;
751
+ lastDeselectSignal = deselectSignal;
752
+ queueMicrotask(() => {
753
+ selectedIds = [];
754
+ onSelectionChange?.(new Set(), false);
755
+ });
756
+ });
757
+
758
+ $effect(() => {
759
+ if (clearSignal === lastClearSignal) return;
760
+ lastClearSignal = clearSignal;
761
+ queueMicrotask(() => {
762
+ drawBox = null;
763
+ previewRects = {};
764
+ guides = [];
765
+ hoverRect = null;
766
+ hoverLabel = null;
767
+ sizeIndicator = null;
768
+ pointerStart = null;
769
+ onInteractionChange?.(false);
770
+ selectedIds = [];
771
+ exitingIds = [];
772
+ exitingConnectors = {};
773
+ onSelectionChange?.(new Set(), false);
774
+ onChange({
775
+ ...rearrangeState,
776
+ sections: [],
777
+ originalOrder: [],
778
+ });
779
+ });
780
+ });
781
+
782
+ $effect(() => {
783
+ if (blankCanvas || typeof MutationObserver === 'undefined') return;
784
+
785
+ const root = document.body;
786
+ if (!root) return;
787
+
788
+ if (!hasMeasuredInitialDom) {
789
+ hasMeasuredInitialDom = true;
790
+ queueMicrotask(() => {
791
+ domRevision += 1;
792
+ });
793
+ }
794
+
795
+ let queued = false;
796
+ const observer = new MutationObserver(() => {
797
+ if (queued) return;
798
+ queued = true;
799
+ queueMicrotask(() => {
800
+ queued = false;
801
+ domRevision += 1;
802
+ });
803
+ });
804
+
805
+ observer.observe(root, {
806
+ childList: true,
807
+ subtree: true,
808
+ characterData: true,
809
+ attributes: true,
810
+ attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'],
811
+ });
812
+
813
+ return () => observer.disconnect();
814
+ });
815
+
816
+ $effect(() => {
817
+ const currentChangedIds = new Set(changedSections.map((section) => section.id));
818
+
819
+ for (const section of changedSections) {
820
+ lastChangedRects.set(section.id, {
821
+ currentRect: { ...getRenderedRect(section) },
822
+ originalRect: { ...section.originalRect },
823
+ isFixed: section.isFixed,
824
+ });
825
+ }
826
+
827
+ for (const [id] of lastChangedRects) {
828
+ if (!rearrangeState.sections.some((section) => section.id === id)) {
829
+ lastChangedRects.delete(id);
830
+ }
831
+ }
832
+
833
+ const connectorExits: Record<string, { orig: Rect; target: Rect; isFixed?: boolean }> = {};
834
+ for (const id of previousChangedIds) {
835
+ if (currentChangedIds.has(id)) continue;
836
+ if (!rearrangeState.sections.some((section) => section.id === id)) continue;
837
+
838
+ const lastKnown = lastChangedRects.get(id);
839
+ if (!lastKnown) continue;
840
+
841
+ connectorExits[id] = {
842
+ orig: lastKnown.originalRect,
843
+ target: lastKnown.currentRect,
844
+ isFixed: lastKnown.isFixed,
845
+ };
846
+ lastChangedRects.delete(id);
847
+ }
848
+
849
+ previousChangedIds.clear();
850
+ for (const id of currentChangedIds) {
851
+ previousChangedIds.add(id);
852
+ }
853
+
854
+ if (Object.keys(connectorExits).length === 0) return;
855
+
856
+ exitingConnectors = { ...exitingConnectors, ...connectorExits };
857
+ const exitIds = Object.keys(connectorExits);
858
+ const timer = originalSetTimeout(() => {
859
+ const next = { ...exitingConnectors };
860
+ for (const id of exitIds) {
861
+ delete next[id];
862
+ }
863
+ exitingConnectors = next;
864
+ }, 250);
865
+
866
+ return () => clearTimeout(timer);
867
+ });
868
+ </script>
869
+
870
+ <svelte:window onkeydown={handleKeyDown} onscroll={syncViewport} onresize={syncViewport} />
871
+ <svelte:document
872
+ onmousemove={!blankCanvas ? handleDocumentMouseMove : undefined}
873
+ onmousedowncapture={!blankCanvas ? handleDocumentMouseDown : undefined}
874
+ />
875
+
876
+ <div
877
+ class={className}
878
+ data-rearrange-mode
879
+ data-exiting={exiting ? 'true' : 'false'}
880
+ role="application"
881
+ aria-label="Rearrange mode canvas"
882
+ style="position: fixed; inset: 0; z-index: 1001; pointer-events: none;"
883
+ >
884
+ {#if hoverRect}
885
+ <div
886
+ style="
887
+ position: absolute;
888
+ left: {hoverRect.x}px;
889
+ top: {hoverRect.y}px;
890
+ width: {hoverRect.width}px;
891
+ height: {hoverRect.height}px;
892
+ border: 1px dashed var(--dry-color-fill-brand, #7c3aed);
893
+ background: color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 6%, transparent);
894
+ border-radius: 12px;
895
+ pointer-events: none;
896
+ "
897
+ >
898
+ {#if hoverLabel}
899
+ <div style="position: absolute; left: 8px; top: -28px;">
900
+ <Badge variant="soft">{hoverLabel}</Badge>
901
+ </div>
902
+ {/if}
903
+ </div>
904
+ {/if}
905
+
906
+ {#if blankCanvas}
907
+ <div
908
+ data-rearrange-draw-layer
909
+ role="button"
910
+ tabindex="-1"
911
+ aria-label="Draw section"
912
+ style="position: fixed; inset: 0; pointer-events: auto;"
913
+ onpointerdown={handleDrawLayerPointerDown}
914
+ ></div>
915
+ {/if}
916
+
917
+ <svg
918
+ aria-hidden="true"
919
+ style="position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible;"
920
+ >
921
+ {#each connectorSections as connector (connector.id)}
922
+ {@const fromX = connector.orig.x + connector.orig.width / 2}
923
+ {@const fromY = (connector.isFixed ? connector.orig.y : connector.orig.y - scrollY) + connector.orig.height / 2}
924
+ {@const toX = connector.target.x + connector.target.width / 2}
925
+ {@const toY = (connector.isFixed ? connector.target.y : connector.target.y - scrollY) + connector.target.height / 2}
926
+ {@const deltaX = toX - fromX}
927
+ {@const deltaY = toY - fromY}
928
+ {@const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)}
929
+ {#if distance > 1}
930
+ {@const normalX = -deltaY / distance}
931
+ {@const normalY = deltaX / distance}
932
+ {@const controlOffset = Math.min(distance * 0.3, 60)}
933
+ {@const controlX = (fromX + toX) / 2 + normalX * controlOffset}
934
+ {@const controlY = (fromY + toY) / 2 + normalY * controlOffset}
935
+ {@const proximityScale = Math.min(1, distance / 40)}
936
+ {@const selected = isSelected(connector.id)}
937
+ {@const isDragging = Boolean(previewRects[connector.id])}
938
+ {@const pathOpacity = connector.exiting ? 0.22 : isDragging || selected ? 0.9 : 0.45}
939
+ {@const dotOpacity = connector.exiting ? 0.35 : isDragging || selected ? 0.88 : 0.72}
940
+ <g data-rearrange-connector-group={connector.id}>
941
+ <path
942
+ data-rearrange-connector
943
+ data-rearrange-connector-exiting={connector.exiting ? 'true' : 'false'}
944
+ d={`M ${fromX} ${fromY} Q ${controlX} ${controlY} ${toX} ${toY}`}
945
+ fill="none"
946
+ stroke="var(--dry-color-fill-brand, #7c3aed)"
947
+ stroke-width="1.5"
948
+ opacity={pathOpacity * proximityScale}
949
+ ></path>
950
+ <circle
951
+ data-rearrange-connector-dot
952
+ cx={fromX}
953
+ cy={fromY}
954
+ r={4 * proximityScale}
955
+ fill="var(--dry-color-fill-brand, #7c3aed)"
956
+ stroke="var(--dry-color-bg-overlay, #fff)"
957
+ stroke-width="1.5"
958
+ opacity={dotOpacity * proximityScale}
959
+ ></circle>
960
+ <circle
961
+ data-rearrange-connector-dot
962
+ cx={toX}
963
+ cy={toY}
964
+ r={4 * proximityScale}
965
+ fill="var(--dry-color-fill-brand, #7c3aed)"
966
+ stroke="var(--dry-color-bg-overlay, #fff)"
967
+ stroke-width="1.5"
968
+ opacity={dotOpacity * proximityScale}
969
+ ></circle>
970
+ </g>
971
+ {/if}
972
+ {/each}
973
+ </svg>
974
+
975
+ {#each unchangedSections as section (section.id)}
976
+ {@const rect = toViewportRect(section)}
977
+ <div
978
+ data-rearrange-section={section.id}
979
+ data-rearrange-state="unchanged"
980
+ role="button"
981
+ tabindex="0"
982
+ aria-label={section.label}
983
+ style="
984
+ position: absolute;
985
+ left: {rect.x}px;
986
+ top: {rect.y}px;
987
+ width: {rect.width}px;
988
+ height: {rect.height}px;
989
+ border: 1px solid color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 55%, white);
990
+ background: color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 7%, transparent);
991
+ border-radius: 12px;
992
+ box-shadow: {isSelected(section.id) ? '0 0 0 2px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 28%, transparent)' : 'none'};
993
+ cursor: move;
994
+ padding: 8px;
995
+ pointer-events: auto;
996
+ opacity: {exitingIds.includes(section.id) ? '0' : '1'};
997
+ transform: {exitingIds.includes(section.id) ? 'scale(0.97)' : 'scale(1)'};
998
+ transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.32, 0.72, 0, 1), box-shadow 0.15s ease;
999
+ "
1000
+ onmousedown={(event) => handleSectionMouseDown(event, section.id)}
1001
+ ondblclick={() => openNoteEditor(section.id)}
1002
+ >
1003
+ <Stack gap="sm">
1004
+ <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
1005
+ <Stack direction="horizontal" gap="sm" align="center">
1006
+ <Badge variant={isSelected(section.id) ? 'solid' : 'soft'}>{section.label}</Badge>
1007
+ <Text as="span" size="sm">{section.tagName}</Text>
1008
+ </Stack>
1009
+ {#if isSelected(section.id)}
1010
+ <Button
1011
+ type="button"
1012
+ variant="ghost"
1013
+ size="sm"
1014
+ aria-label={`Delete ${section.label}`}
1015
+ onclick={() => removeSections([section.id])}
1016
+ >
1017
+ ×
1018
+ </Button>
1019
+ {/if}
1020
+ </div>
1021
+ <Text as="div" size="sm">{Math.round(rect.width)} x {Math.round(rect.height)}</Text>
1022
+ {#if section.note}
1023
+ <Text as="div" size="sm" color="secondary">{section.note}</Text>
1024
+ {/if}
1025
+ </Stack>
1026
+
1027
+ {#if isSelected(section.id) && selectedIds.length === 1}
1028
+ {#each CORNER_HANDLES as handle (handle)}
1029
+ <button
1030
+ type="button"
1031
+ aria-label={`Resize ${handle}`}
1032
+ style={`
1033
+ position: absolute;
1034
+ width: 8px;
1035
+ height: 8px;
1036
+ border-radius: 3px;
1037
+ border: 1px solid var(--dry-color-bg-overlay, #fff);
1038
+ background: var(--dry-color-bg-overlay, #fff);
1039
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 36%, transparent);
1040
+ pointer-events: auto;
1041
+ z-index: 20;
1042
+ cursor: ${handleCursor(handle)};
1043
+ ${cornerHandleOffset(handle)}
1044
+ `}
1045
+ onmousedown={(event) => startResize(event, section.id, handle)}
1046
+ ></button>
1047
+ {/each}
1048
+ {#each EDGE_HANDLES as handle (handle)}
1049
+ <button
1050
+ type="button"
1051
+ aria-label={`Resize ${handle}`}
1052
+ style={`
1053
+ position: absolute;
1054
+ display: flex;
1055
+ align-items: center;
1056
+ justify-content: center;
1057
+ padding: 0;
1058
+ border: none;
1059
+ background: transparent;
1060
+ pointer-events: auto;
1061
+ z-index: 18;
1062
+ cursor: ${handleCursor(handle)};
1063
+ ${edgeHandleInset(handle)}
1064
+ `}
1065
+ onmousedown={(event) => startResize(event, section.id, handle)}
1066
+ >
1067
+ <span
1068
+ aria-hidden="true"
1069
+ style={`
1070
+ position: absolute;
1071
+ border-radius: 999px;
1072
+ background: color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 82%, white);
1073
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 20%, transparent);
1074
+ ${handle === 'n' || handle === 's' ? 'width: 24px; height: 4px;' : 'width: 4px; height: 24px;'}
1075
+ `}
1076
+ ></span>
1077
+ <svg
1078
+ aria-hidden="true"
1079
+ viewBox="0 0 12 12"
1080
+ style="position: relative; z-index: 1; width: 12px; height: 12px; opacity: 0.72; color: var(--dry-color-bg-overlay, #fff); filter: drop-shadow(0 0 2px color-mix(in srgb, var(--dry-color-text-strong, #111827) 18%, transparent));"
1081
+ >
1082
+ {#if handle === 'n' || handle === 's'}
1083
+ <path d="M6 2 L3.5 4.5 H8.5 Z" fill="currentColor"></path>
1084
+ <path d="M6 10 L3.5 7.5 H8.5 Z" fill="currentColor"></path>
1085
+ {:else}
1086
+ <path d="M2 6 L4.5 3.5 V8.5 Z" fill="currentColor"></path>
1087
+ <path d="M10 6 L7.5 3.5 V8.5 Z" fill="currentColor"></path>
1088
+ {/if}
1089
+ </svg>
1090
+ </button>
1091
+ {/each}
1092
+ {/if}
1093
+ </div>
1094
+ {/each}
1095
+
1096
+ {#each changedSections as section (section.id)}
1097
+ {@const renderedRect = getRenderedRect(section)}
1098
+ {@const rect = toViewportRect(section, renderedRect)}
1099
+ {@const changeLabel = suggestedChangeLabel(section, renderedRect)}
1100
+ <div
1101
+ data-rearrange-section={section.id}
1102
+ data-rearrange-state="changed"
1103
+ role="button"
1104
+ tabindex="0"
1105
+ aria-label={section.label}
1106
+ style="
1107
+ position: absolute;
1108
+ left: {rect.x}px;
1109
+ top: {rect.y}px;
1110
+ width: {rect.width}px;
1111
+ height: {rect.height}px;
1112
+ border: 1.5px dashed color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 72%, white);
1113
+ background: {isSelected(section.id) ? 'color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 9%, transparent)' : 'transparent'};
1114
+ border-radius: 12px;
1115
+ box-shadow: {isSelected(section.id) ? '0 0 0 2px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 28%, transparent)' : 'none'};
1116
+ cursor: move;
1117
+ padding: 8px;
1118
+ pointer-events: auto;
1119
+ opacity: {exitingIds.includes(section.id) ? '0' : '1'};
1120
+ transform: {exitingIds.includes(section.id) ? 'scale(0.97)' : 'scale(1)'};
1121
+ transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.32, 0.72, 0, 1), box-shadow 0.15s ease;
1122
+ "
1123
+ onmousedown={(event) => handleSectionMouseDown(event, section.id)}
1124
+ ondblclick={() => openNoteEditor(section.id)}
1125
+ >
1126
+ {#if changeLabel}
1127
+ <div style="position: absolute; left: 8px; top: -28px; pointer-events: none;">
1128
+ <Badge variant="outline">{changeLabel}</Badge>
1129
+ </div>
1130
+ {/if}
1131
+
1132
+ <Stack gap="sm">
1133
+ <div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
1134
+ <Stack direction="horizontal" gap="sm" align="center">
1135
+ <Badge variant={isSelected(section.id) ? 'solid' : 'soft'}>{section.label}</Badge>
1136
+ <Text as="span" size="sm">{section.tagName}</Text>
1137
+ </Stack>
1138
+ {#if isSelected(section.id)}
1139
+ <Button
1140
+ type="button"
1141
+ variant="ghost"
1142
+ size="sm"
1143
+ aria-label={`Delete ${section.label}`}
1144
+ onclick={() => removeSections([section.id])}
1145
+ >
1146
+ ×
1147
+ </Button>
1148
+ {/if}
1149
+ </div>
1150
+ <Text as="div" size="sm">{Math.round(rect.width)} x {Math.round(rect.height)}</Text>
1151
+ </Stack>
1152
+
1153
+ {#if section.note}
1154
+ <div style="position: absolute; left: 0; right: 0; bottom: -20px; pointer-events: none;">
1155
+ <Text as="div" size="sm" color="secondary">{section.note}</Text>
1156
+ </div>
1157
+ {/if}
1158
+
1159
+ {#if isSelected(section.id) && selectedIds.length === 1}
1160
+ {#each CORNER_HANDLES as handle (handle)}
1161
+ <button
1162
+ type="button"
1163
+ aria-label={`Resize ${handle}`}
1164
+ style={`
1165
+ position: absolute;
1166
+ width: 8px;
1167
+ height: 8px;
1168
+ border-radius: 3px;
1169
+ border: 1px solid var(--dry-color-bg-overlay, #fff);
1170
+ background: var(--dry-color-bg-overlay, #fff);
1171
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 36%, transparent);
1172
+ pointer-events: auto;
1173
+ z-index: 20;
1174
+ cursor: ${handleCursor(handle)};
1175
+ ${cornerHandleOffset(handle)}
1176
+ `}
1177
+ onmousedown={(event) => startResize(event, section.id, handle)}
1178
+ ></button>
1179
+ {/each}
1180
+ {#each EDGE_HANDLES as handle (handle)}
1181
+ <button
1182
+ type="button"
1183
+ aria-label={`Resize ${handle}`}
1184
+ style={`
1185
+ position: absolute;
1186
+ display: flex;
1187
+ align-items: center;
1188
+ justify-content: center;
1189
+ padding: 0;
1190
+ border: none;
1191
+ background: transparent;
1192
+ pointer-events: auto;
1193
+ z-index: 18;
1194
+ cursor: ${handleCursor(handle)};
1195
+ ${edgeHandleInset(handle)}
1196
+ `}
1197
+ onmousedown={(event) => startResize(event, section.id, handle)}
1198
+ >
1199
+ <span
1200
+ aria-hidden="true"
1201
+ style={`
1202
+ position: absolute;
1203
+ border-radius: 999px;
1204
+ background: color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 82%, white);
1205
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 20%, transparent);
1206
+ ${handle === 'n' || handle === 's' ? 'width: 24px; height: 4px;' : 'width: 4px; height: 24px;'}
1207
+ `}
1208
+ ></span>
1209
+ <svg
1210
+ aria-hidden="true"
1211
+ viewBox="0 0 12 12"
1212
+ style="position: relative; z-index: 1; width: 12px; height: 12px; opacity: 0.72; color: var(--dry-color-bg-overlay, #fff); filter: drop-shadow(0 0 2px color-mix(in srgb, var(--dry-color-text-strong, #111827) 18%, transparent));"
1213
+ >
1214
+ {#if handle === 'n' || handle === 's'}
1215
+ <path d="M6 2 L3.5 4.5 H8.5 Z" fill="currentColor"></path>
1216
+ <path d="M6 10 L3.5 7.5 H8.5 Z" fill="currentColor"></path>
1217
+ {:else}
1218
+ <path d="M2 6 L4.5 3.5 V8.5 Z" fill="currentColor"></path>
1219
+ <path d="M10 6 L7.5 3.5 V8.5 Z" fill="currentColor"></path>
1220
+ {/if}
1221
+ </svg>
1222
+ </button>
1223
+ {/each}
1224
+ {/if}
1225
+ </div>
1226
+ {/each}
1227
+
1228
+ {#if drawBox}
1229
+ <div
1230
+ style="
1231
+ position: absolute;
1232
+ left: {drawBox.x}px;
1233
+ top: {drawBox.y}px;
1234
+ width: {drawBox.width}px;
1235
+ height: {drawBox.height}px;
1236
+ border: 1px dashed var(--dry-color-fill-brand, #7c3aed);
1237
+ background: color-mix(in srgb, var(--dry-color-fill-brand, #7c3aed) 8%, transparent);
1238
+ pointer-events: none;
1239
+ "
1240
+ ></div>
1241
+ {/if}
1242
+
1243
+ {#each guides as guide, index (index)}
1244
+ <div
1245
+ style={`position: absolute; pointer-events: none; background: var(--dry-color-fill-brand, #7c3aed); opacity: 0.4; ${guide.axis === 'x' ? `left: ${guide.pos}px; top: 0; width: 1px; height: 100vh;` : `top: ${guide.pos - scrollY}px; left: 0; height: 1px; width: 100vw;`}`}
1246
+ ></div>
1247
+ {/each}
1248
+
1249
+ {#if sizeIndicator}
1250
+ <div
1251
+ style="
1252
+ position: fixed;
1253
+ left: {sizeIndicator.x}px;
1254
+ top: {sizeIndicator.y}px;
1255
+ z-index: 1003;
1256
+ padding: 6px 10px;
1257
+ border-radius: 999px;
1258
+ background: var(--dry-color-bg-overlay, #fff);
1259
+ border: 1px solid var(--dry-color-stroke-weak, #ddd);
1260
+ pointer-events: none;
1261
+ "
1262
+ >
1263
+ <Text as="div" size="sm">{sizeIndicator.text}</Text>
1264
+ </div>
1265
+ {/if}
1266
+
1267
+ {#if noteTargetId}
1268
+ {@const noteSection = rearrangeState.sections.find((section) => section.id === noteTargetId)}
1269
+ {#if noteSection}
1270
+ {#key noteSection.id}
1271
+ <AnnotationPopup
1272
+ element={noteSection.label}
1273
+ initialValue={noteSection.note ?? ''}
1274
+ showDelete={Boolean(noteSection.note)}
1275
+ position={getNotePopupPosition(noteSection)}
1276
+ onsubmit={saveNote}
1277
+ oncancel={cancelNote}
1278
+ ondelete={clearNote}
1279
+ />
1280
+ {/key}
1281
+ {/if}
1282
+ {/if}
1283
+
1284
+ <div
1285
+ data-rearrange-mode-controls
1286
+ style="position: fixed; right: 16px; bottom: 16px; pointer-events: auto;"
1287
+ >
1288
+ <Stack gap="sm" align="end">
1289
+ <Button variant="outline" onclick={captureSections}>Capture sections</Button>
1290
+ <Button variant="ghost" onclick={clearAll}>Clear all</Button>
1291
+ </Stack>
1292
+ </div>
1293
+ </div>