@brixter/brix-builder 0.0.1

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/core.d.ts +103 -0
  4. package/dist/core.d.ts.map +1 -0
  5. package/dist/core.js +758 -0
  6. package/dist/core.js.map +1 -0
  7. package/dist/editor/BuilderApp.svelte +1299 -0
  8. package/dist/editor/BuilderFieldEditor.svelte +274 -0
  9. package/dist/editor/BuilderInspector.svelte +123 -0
  10. package/dist/editor/BuilderPreviewFrame.svelte +661 -0
  11. package/dist/editor/ComponentPreviewThumbnail.svelte +197 -0
  12. package/dist/editor/PageFlowSidebar.svelte +198 -0
  13. package/dist/editor/PreviewBlockInserter.svelte +35 -0
  14. package/dist/editor/PreviewIconEditor.svelte +213 -0
  15. package/dist/editor/PreviewImageEditor.svelte +221 -0
  16. package/dist/editor/PreviewTextEditor.svelte +246 -0
  17. package/dist/editor/RichTextEditor.svelte +234 -0
  18. package/dist/editor/contracts.d.ts +57 -0
  19. package/dist/editor/contracts.d.ts.map +1 -0
  20. package/dist/editor/contracts.js +2 -0
  21. package/dist/editor/contracts.js.map +1 -0
  22. package/dist/editor/index.d.ts +3 -0
  23. package/dist/editor/index.d.ts.map +1 -0
  24. package/dist/editor/index.js +2 -0
  25. package/dist/editor/index.js.map +1 -0
  26. package/dist/editor/shortcuts.d.ts +28 -0
  27. package/dist/editor/shortcuts.d.ts.map +1 -0
  28. package/dist/editor/shortcuts.js +28 -0
  29. package/dist/editor/shortcuts.js.map +1 -0
  30. package/dist/editor-controller.d.ts +50 -0
  31. package/dist/editor-controller.d.ts.map +1 -0
  32. package/dist/editor-controller.js +157 -0
  33. package/dist/editor-controller.js.map +1 -0
  34. package/dist/index.d.ts +7 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +6 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/preview/field-edit-debug.d.ts +5 -0
  39. package/dist/preview/field-edit-debug.d.ts.map +1 -0
  40. package/dist/preview/field-edit-debug.js +36 -0
  41. package/dist/preview/field-edit-debug.js.map +1 -0
  42. package/dist/preview/interactive-content.d.ts +8 -0
  43. package/dist/preview/interactive-content.d.ts.map +1 -0
  44. package/dist/preview/interactive-content.js +62 -0
  45. package/dist/preview/interactive-content.js.map +1 -0
  46. package/dist/preview-dom.d.ts +67 -0
  47. package/dist/preview-dom.d.ts.map +1 -0
  48. package/dist/preview-dom.js +191 -0
  49. package/dist/preview-dom.js.map +1 -0
  50. package/dist/svelte/SveltePreviewRenderer.svelte +490 -0
  51. package/dist/svelte/adapter.d.ts +7 -0
  52. package/dist/svelte/adapter.d.ts.map +1 -0
  53. package/dist/svelte/adapter.js +66 -0
  54. package/dist/svelte/adapter.js.map +1 -0
  55. package/dist/svelte/index.d.ts +3 -0
  56. package/dist/svelte/index.d.ts.map +1 -0
  57. package/dist/svelte/index.js +3 -0
  58. package/dist/svelte/index.js.map +1 -0
  59. package/dist/svelte/markup-schema.d.ts +5 -0
  60. package/dist/svelte/markup-schema.d.ts.map +1 -0
  61. package/dist/svelte/markup-schema.js +177 -0
  62. package/dist/svelte/markup-schema.js.map +1 -0
  63. package/package.json +56 -0
@@ -0,0 +1,197 @@
1
+ <script lang="ts">
2
+ import { mount, unmount } from 'svelte';
3
+ import { createBuilderFallbackProps, normalizeBuilderPropsForRender } from '../core.js';
4
+ import type { BuilderRenderDefinition } from './contracts.js';
5
+
6
+ let { definition }: { definition: BuilderRenderDefinition } = $props();
7
+
8
+ function thumbnailFrame(node: HTMLIFrameElement): { update: () => void; destroy: () => void } {
9
+ let renderer: Record<string, unknown> | null = null;
10
+ let cleanupHeadSync: (() => void) | null = null;
11
+ let destroyed = false;
12
+ const sourceDocument = node.ownerDocument;
13
+
14
+ void render();
15
+
16
+ async function render(): Promise<void> {
17
+ const frameDocument = node.contentDocument;
18
+ if (!frameDocument) {
19
+ return;
20
+ }
21
+
22
+ if (renderer) {
23
+ await unmount(renderer);
24
+ renderer = null;
25
+ }
26
+ cleanupHeadSync?.();
27
+ cleanupHeadSync = null;
28
+
29
+ frameDocument.open();
30
+ frameDocument.write(`<!doctype html>
31
+ <html>
32
+ <head>
33
+ <meta charset="utf-8">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1">
35
+ <base href="${escapeHtml(sourceDocument.baseURI)}">
36
+ </head>
37
+ <body>
38
+ <div id="component-preview-stage">
39
+ <div id="component-preview-root"></div>
40
+ </div>
41
+ </body>
42
+ </html>`);
43
+ frameDocument.close();
44
+
45
+ await Promise.resolve();
46
+ if (destroyed) {
47
+ return;
48
+ }
49
+
50
+ setupFrameDocument(frameDocument, sourceDocument);
51
+ cleanupHeadSync = syncHeadAssets(frameDocument, sourceDocument);
52
+
53
+ const target = frameDocument.getElementById('component-preview-root');
54
+ if (!target) {
55
+ return;
56
+ }
57
+
58
+ renderer = mount(definition.component, {
59
+ target,
60
+ props: normalizeBuilderPropsForRender(createBuilderFallbackProps(definition)) as Record<
61
+ string,
62
+ unknown
63
+ >
64
+ }) as Record<string, unknown>;
65
+ }
66
+
67
+ return {
68
+ update() {
69
+ void render();
70
+ },
71
+ destroy() {
72
+ destroyed = true;
73
+ cleanupHeadSync?.();
74
+ cleanupHeadSync = null;
75
+ if (renderer) {
76
+ void unmount(renderer);
77
+ renderer = null;
78
+ }
79
+ }
80
+ };
81
+ }
82
+
83
+ function setupFrameDocument(frameDocument: Document, sourceDocument: Document): void {
84
+ const styleElement = frameDocument.createElement('style');
85
+ styleElement.textContent = `
86
+ html,
87
+ body,
88
+ #component-preview-stage {
89
+ margin: 0;
90
+ min-height: 100%;
91
+ }
92
+
93
+ body {
94
+ background: transparent;
95
+ color: #1e1c18;
96
+ overflow: hidden;
97
+ }
98
+
99
+ * {
100
+ scrollbar-width: none;
101
+ }
102
+
103
+ *::-webkit-scrollbar {
104
+ display: none;
105
+ }
106
+
107
+ body.dark {
108
+ background: transparent;
109
+ color: #f3f4f6;
110
+ }
111
+
112
+ #component-preview-stage {
113
+ box-sizing: border-box;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ min-height: 640px;
118
+ padding: 32px;
119
+ background:
120
+ linear-gradient(90deg, rgba(15, 23, 42, 0.07) 1px, transparent 1px),
121
+ linear-gradient(0deg, rgba(15, 23, 42, 0.07) 1px, transparent 1px),
122
+ radial-gradient(circle at top left, rgba(253, 224, 71, 0.18), transparent 34%),
123
+ linear-gradient(135deg, #fafaf5 0%, #fef9c3 100%);
124
+ background-size:
125
+ 24px 24px,
126
+ 24px 24px,
127
+ auto,
128
+ auto;
129
+ }
130
+
131
+ .dark #component-preview-stage {
132
+ background:
133
+ linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
134
+ linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
135
+ radial-gradient(circle at top left, rgba(253, 224, 71, 0.24), transparent 34%),
136
+ linear-gradient(135deg, #12100d 0%, #2d2a25 100%);
137
+ background-size:
138
+ 24px 24px,
139
+ 24px 24px,
140
+ auto,
141
+ auto;
142
+ }
143
+
144
+ #component-preview-root {
145
+ width: 100%;
146
+ }
147
+ `;
148
+ frameDocument.head.append(styleElement);
149
+ syncThemeClass(frameDocument, sourceDocument);
150
+ }
151
+
152
+ function syncHeadAssets(frameDocument: Document, sourceDocument: Document): () => void {
153
+ for (const asset of sourceDocument.head.querySelectorAll(
154
+ 'link[rel="stylesheet"], link[rel="preconnect"], style'
155
+ )) {
156
+ const clone = asset.cloneNode(true) as HTMLElement;
157
+ if (asset instanceof HTMLLinkElement && clone instanceof HTMLLinkElement && asset.href) {
158
+ clone.href = asset.href;
159
+ }
160
+ frameDocument.head.append(clone);
161
+ }
162
+
163
+ return () => {
164
+ for (const asset of Array.from(frameDocument.head.children)) {
165
+ asset.remove();
166
+ }
167
+ };
168
+ }
169
+
170
+ function syncThemeClass(frameDocument: Document, sourceDocument: Document): void {
171
+ const isDark =
172
+ sourceDocument.documentElement.classList.contains('dark') ||
173
+ sourceDocument.body.classList.contains('dark');
174
+ frameDocument.documentElement.classList.toggle('dark', isDark);
175
+ frameDocument.body.classList.toggle('dark', isDark);
176
+ }
177
+
178
+ function escapeHtml(value: string): string {
179
+ return value
180
+ .replaceAll('&', '&amp;')
181
+ .replaceAll('"', '&quot;')
182
+ .replaceAll('<', '&lt;')
183
+ .replaceAll('>', '&gt;');
184
+ }
185
+ </script>
186
+
187
+ <div
188
+ class="relative h-48 w-full overflow-hidden border border-gray-200 bg-[radial-gradient(circle_at_top_left,rgba(253,224,71,0.18),transparent_34%),linear-gradient(135deg,#fafaf5_0%,#fef9c3_100%)] dark:border-gray-700 dark:bg-[radial-gradient(circle_at_top_left,rgba(253,224,71,0.24),transparent_34%),linear-gradient(135deg,#12100d_0%,#2d2a25_100%)]"
189
+ >
190
+ <iframe
191
+ use:thumbnailFrame
192
+ title={`Anteprima ${definition.type}`}
193
+ scrolling="no"
194
+ class="pointer-events-none absolute top-0 left-0 h-[640px] w-full origin-top-left scale-[0.3] border-0 bg-transparent"
195
+ style="width: 333.333%;"
196
+ ></iframe>
197
+ </div>
@@ -0,0 +1,198 @@
1
+ <script lang="ts">
2
+ import type { BuilderBlock } from '../core.js';
3
+
4
+ let {
5
+ blocks,
6
+ activeBlockId,
7
+ onSelectBlock,
8
+ onDeselectBlock,
9
+ onMoveBlock,
10
+ onRemoveBlock,
11
+ onDragStart,
12
+ onAllowDrop,
13
+ onDrop
14
+ }: {
15
+ blocks: BuilderBlock[];
16
+ activeBlockId: string | null;
17
+ onSelectBlock: (blockId: string) => void;
18
+ onDeselectBlock: () => void;
19
+ onMoveBlock: (blockId: string, direction: -1 | 1) => void;
20
+ onRemoveBlock: (blockId: string) => void;
21
+ onDragStart: (blockId: string) => void;
22
+ onAllowDrop: (event: DragEvent) => void;
23
+ onDrop: (blockId: string) => void;
24
+ } = $props();
25
+
26
+ let openActionBlockId = $state<string | null>(null);
27
+ let draggedBlockId = $state<string | null>(null);
28
+ let dropTargetBlockId = $state<string | null>(null);
29
+
30
+ function toggleActionMenu(blockId: string, event: MouseEvent): void {
31
+ event.stopPropagation();
32
+ openActionBlockId = openActionBlockId === blockId ? null : blockId;
33
+ }
34
+
35
+ function closeActionMenu(event?: MouseEvent): void {
36
+ event?.stopPropagation();
37
+ openActionBlockId = null;
38
+ }
39
+
40
+ function moveBlock(blockId: string, direction: -1 | 1): void {
41
+ onMoveBlock(blockId, direction);
42
+ closeActionMenu();
43
+ }
44
+
45
+ function removeBlock(blockId: string): void {
46
+ onRemoveBlock(blockId);
47
+ closeActionMenu();
48
+ }
49
+
50
+ function startDrag(blockId: string): void {
51
+ draggedBlockId = blockId;
52
+ dropTargetBlockId = null;
53
+ onDragStart(blockId);
54
+ }
55
+
56
+ function dragOverBlock(blockId: string, event: DragEvent): void {
57
+ onAllowDrop(event);
58
+ dropTargetBlockId = blockId;
59
+ }
60
+
61
+ function leaveBlock(blockId: string, event: DragEvent): void {
62
+ if (
63
+ dropTargetBlockId !== blockId ||
64
+ (event.relatedTarget instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ dropTargetBlockId = null;
70
+ }
71
+
72
+ function dropBlock(blockId: string): void {
73
+ onDrop(blockId);
74
+ draggedBlockId = null;
75
+ dropTargetBlockId = null;
76
+ }
77
+
78
+ function endDrag(): void {
79
+ draggedBlockId = null;
80
+ dropTargetBlockId = null;
81
+ }
82
+
83
+ function getDropPlacement(blockId: string): 'before' | 'after' | null {
84
+ if (!draggedBlockId || draggedBlockId === blockId || dropTargetBlockId !== blockId) {
85
+ return null;
86
+ }
87
+
88
+ const draggedIndex = blocks.findIndex((block) => block.id === draggedBlockId);
89
+ const targetIndex = blocks.findIndex((block) => block.id === blockId);
90
+ if (draggedIndex === -1 || targetIndex === -1) {
91
+ return null;
92
+ }
93
+
94
+ return draggedIndex < targetIndex ? 'after' : 'before';
95
+ }
96
+ </script>
97
+
98
+ <aside class="flex h-full min-h-0 w-full flex-col border-r border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900" aria-label="Page flow" onclick={(event) => { if (!(event.target as Element).closest('[data-page-flow-block]')) onDeselectBlock(); }}>
99
+ <div class="min-h-0 flex-1 overflow-y-auto py-2">
100
+ {#if blocks.length === 0}
101
+ <p class="text-muted mx-3 border border-dashed border-gray-300 px-4 py-6 text-sm dark:border-gray-600">
102
+ No briks on the page.
103
+ </p>
104
+ {/if}
105
+
106
+ <div role="list" aria-label="Page flow">
107
+ {#each blocks as block (block.id)}
108
+ {@const dropPlacement = getDropPlacement(block.id)}
109
+ <div
110
+ data-page-flow-block
111
+ class={activeBlockId === block.id
112
+ ? 'relative flex items-center gap-2 border-l-4 border-[#FDE047] bg-yellow-50 px-3 py-2 text-gray-900 dark:border-[#FACC15] dark:bg-yellow-950/40 dark:text-gray-100'
113
+ : 'relative flex items-center gap-2 border-l-4 border-transparent px-3 py-2 text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-700'}
114
+ role="listitem"
115
+ draggable={true}
116
+ ondragstart={() => startDrag(block.id)}
117
+ ondragover={(event) => dragOverBlock(block.id, event)}
118
+ ondragleave={(event) => leaveBlock(block.id, event)}
119
+ ondrop={() => dropBlock(block.id)}
120
+ ondragend={endDrag}>
121
+ {#if dropPlacement === 'before'}
122
+ <div class="pointer-events-none absolute -top-0.5 right-2 left-2 z-30 h-0.5 bg-[#FDE047] shadow-[0_0_0_1px_rgba(253,224,71,0.15)] dark:bg-[#FACC15]"></div>
123
+ {:else if dropPlacement === 'after'}
124
+ <div class="pointer-events-none absolute right-2 -bottom-0.5 left-2 z-30 h-0.5 bg-[#FDE047] shadow-[0_0_0_1px_rgba(253,224,71,0.15)] dark:bg-[#FACC15]"></div>
125
+ {/if}
126
+ <button
127
+ type="button"
128
+ class="absolute inset-0 z-0 cursor-pointer"
129
+ aria-label={`Select brik ${block.type}`}
130
+ onclick={() => onSelectBlock(block.id)}></button>
131
+ <span class="pointer-events-none relative z-10 min-w-0 flex-1 truncate text-sm font-medium">
132
+ {block.type}
133
+ </span>
134
+
135
+ <div class="relative z-20 shrink-0">
136
+ <button
137
+ type="button"
138
+ class="flex h-7 w-7 items-center justify-center border border-transparent text-gray-700 transition-colors hover:border-gray-300 hover:bg-white dark:text-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-800"
139
+ aria-label={`Azioni per ${block.type}`}
140
+ aria-expanded={openActionBlockId === block.id}
141
+ onclick={(event) => toggleActionMenu(block.id, event)}>
142
+ <svg class="h-4 w-4 opacity-50" viewBox="0 0 16 16" aria-hidden="true">
143
+ <circle cx="3" cy="8" r="1.4" fill="currentColor" />
144
+ <circle cx="8" cy="8" r="1.4" fill="currentColor" />
145
+ <circle cx="13" cy="8" r="1.4" fill="currentColor" />
146
+ </svg>
147
+ </button>
148
+
149
+ {#if openActionBlockId === block.id}
150
+ <button
151
+ type="button"
152
+ class="fixed inset-0 z-40 cursor-default bg-black/20 dark:bg-black/40"
153
+ aria-label="Close actions menu"
154
+ onclick={(event) => closeActionMenu(event)}></button>
155
+
156
+ <div
157
+ class="absolute right-0 z-50 mt-1 grid min-w-48 gap-1 border border-gray-200 bg-white p-1.5 text-sm shadow-lg dark:border-gray-700 dark:bg-gray-800">
158
+ <button
159
+ type="button"
160
+ class="flex items-center gap-3 px-3.5 py-2 text-left text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-700"
161
+ onclick={() => moveBlock(block.id, -1)}>
162
+ <svg class="h-4 w-4" viewBox="0 0 16 16" aria-hidden="true">
163
+ <path
164
+ d="M8 3.25 3.75 7.5l.9.9L7.35 5.7V13h1.3V5.7l2.7 2.7.9-.9L8 3.25Z"
165
+ fill="currentColor" />
166
+ </svg>
167
+ Move up
168
+ </button>
169
+ <button
170
+ type="button"
171
+ class="flex items-center gap-3 px-3.5 py-2 text-left text-gray-900 transition-colors hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-700"
172
+ onclick={() => moveBlock(block.id, 1)}>
173
+ <svg class="h-4 w-4" viewBox="0 0 16 16" aria-hidden="true">
174
+ <path
175
+ d="M8 12.75 3.75 8.5l.9-.9 2.7 2.7V3h1.3v7.3l2.7-2.7.9.9L8 12.75Z"
176
+ fill="currentColor" />
177
+ </svg>
178
+ Move down
179
+ </button>
180
+ <button
181
+ type="button"
182
+ class="flex items-center gap-3 px-3.5 py-2 text-left text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
183
+ onclick={() => removeBlock(block.id)}>
184
+ <svg class="h-4 w-4" viewBox="0 0 16 16" aria-hidden="true">
185
+ <path
186
+ d="M6.25 2.5h3.5l.6 1.25H13V5H3V3.75h2.65l.6-1.25Zm-1.8 3.75h1.25l.35 6.25h3.9l.35-6.25h1.25l-.4 7.5H4.85l-.4-7.5Z"
187
+ fill="currentColor" />
188
+ </svg>
189
+ Remove
190
+ </button>
191
+ </div>
192
+ {/if}
193
+ </div>
194
+ </div>
195
+ {/each}
196
+ </div>
197
+ </div>
198
+ </aside>
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ let {
3
+ placement = 'after',
4
+ edgeInset = false,
5
+ onToggle
6
+ }: {
7
+ placement?: 'before' | 'after';
8
+ edgeInset?: boolean;
9
+ onToggle: () => void;
10
+ } = $props();
11
+ </script>
12
+
13
+ <div
14
+ class={placement === 'before'
15
+ ? edgeInset
16
+ ? 'pointer-events-none absolute top-2 right-0 left-0 z-40 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100'
17
+ : 'pointer-events-none absolute top-0 right-0 left-0 z-40 -translate-y-1/2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100'
18
+ : edgeInset
19
+ ? 'pointer-events-none absolute right-0 bottom-2 left-0 z-40 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100'
20
+ : 'pointer-events-none absolute right-0 bottom-0 left-0 z-40 translate-y-1/2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100'}
21
+ >
22
+ <div class="pointer-events-auto relative mx-auto flex w-max justify-center">
23
+ <button
24
+ type="button"
25
+ class="btn-brutal-icon relative flex h-8 w-8 items-center justify-center text-lg leading-none"
26
+ aria-label="Aggiungi componente"
27
+ onclick={(event) => {
28
+ event.stopPropagation();
29
+ onToggle();
30
+ }}
31
+ >
32
+ +
33
+ </button>
34
+ </div>
35
+ </div>
@@ -0,0 +1,213 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { HelpCircle, Trash2 } from 'lucide-svelte';
4
+
5
+ let {
6
+ element,
7
+ onPick,
8
+ onRemove,
9
+ onFocus,
10
+ onBlur
11
+ }: {
12
+ element: HTMLElement;
13
+ onPick: () => void;
14
+ onRemove: () => void;
15
+ onFocus: () => void;
16
+ onBlur: () => void;
17
+ } = $props();
18
+
19
+ let coords = $state({ top: 0, left: 0, width: 0, height: 0 });
20
+ let isOpen = $state(false);
21
+
22
+ let hasIcon = $derived(
23
+ element &&
24
+ !element.hasAttribute('data-builder-icon-empty') &&
25
+ element.innerHTML.trim() !== ''
26
+ );
27
+
28
+ function updateCoords() {
29
+ const rect = element.getBoundingClientRect();
30
+ coords = {
31
+ top: rect.top,
32
+ left: rect.left,
33
+ width: rect.width,
34
+ height: rect.height
35
+ };
36
+ }
37
+
38
+ function clickListener(node: HTMLButtonElement, action: () => void) {
39
+ const handler = (event: MouseEvent) => {
40
+ event.stopPropagation();
41
+ action();
42
+ };
43
+ node.addEventListener('click', handler);
44
+ return {
45
+ destroy() {
46
+ node.removeEventListener('click', handler);
47
+ }
48
+ };
49
+ }
50
+
51
+ export function close() {
52
+ isOpen = false;
53
+ }
54
+
55
+ onMount(() => {
56
+ updateCoords();
57
+
58
+ const win = element.ownerDocument.defaultView || window;
59
+ win.addEventListener('scroll', updateCoords, { passive: true });
60
+ win.addEventListener('resize', updateCoords, { passive: true });
61
+
62
+ // Click to activate and open menu
63
+ const handleElementClick = (event: MouseEvent) => {
64
+ event.stopPropagation();
65
+ isOpen = true;
66
+ onFocus();
67
+ updateCoords();
68
+ };
69
+ element.addEventListener('click', handleElementClick);
70
+
71
+ // Click outside to blur/close
72
+ const handleGlobalClick = (event: MouseEvent) => {
73
+ const target = event.target as Element;
74
+ if (!target) return;
75
+
76
+ if (
77
+ !target.closest('.builder-preview-icon-menu') &&
78
+ target !== element &&
79
+ !element.contains(target)
80
+ ) {
81
+ isOpen = false;
82
+ onBlur();
83
+ }
84
+ };
85
+
86
+ win.document.addEventListener('click', handleGlobalClick, true);
87
+
88
+ // Observe changes in element structure to update coordinates
89
+ const observer = new MutationObserver(() => {
90
+ updateCoords();
91
+ });
92
+ observer.observe(element, { attributes: true, childList: true, subtree: true });
93
+
94
+ return () => {
95
+ win.removeEventListener('scroll', updateCoords);
96
+ win.removeEventListener('resize', updateCoords);
97
+ element.removeEventListener('click', handleElementClick);
98
+ win.document.removeEventListener('click', handleGlobalClick, true);
99
+ observer.disconnect();
100
+ };
101
+ });
102
+ </script>
103
+
104
+ {#if isOpen}
105
+ <div
106
+ class="builder-preview-icon-menu"
107
+ style="
108
+ position: fixed;
109
+ top: {coords.top + coords.height + 6}px;
110
+ left: {coords.left + coords.width / 2}px;
111
+ transform: translate(-50%, 0);
112
+ z-index: 99999;
113
+ "
114
+ >
115
+ <button
116
+ type="button"
117
+ use:clickListener={onPick}
118
+ class="menu-item"
119
+ >
120
+ <HelpCircle size={14} />
121
+ <span>Choose Icon...</span>
122
+ </button>
123
+ {#if hasIcon}
124
+ <button
125
+ type="button"
126
+ use:clickListener={onRemove}
127
+ class="menu-item menu-item-danger"
128
+ >
129
+ <Trash2 size={14} />
130
+ <span>Remove Icon</span>
131
+ </button>
132
+ {/if}
133
+ </div>
134
+ {/if}
135
+
136
+ <style>
137
+ .builder-preview-icon-menu {
138
+ display: flex;
139
+ flex-direction: column;
140
+ border: 1px solid rgba(0, 0, 0, 0.08);
141
+ background-color: rgba(255, 255, 255, 0.95);
142
+ backdrop-filter: blur(12px);
143
+ -webkit-backdrop-filter: blur(12px);
144
+ border-radius: 8px;
145
+ padding: 4px;
146
+ font-size: 0.75rem;
147
+ color: #2d2a25;
148
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
149
+ min-width: 140px;
150
+ animation: popover-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
151
+ }
152
+
153
+ @keyframes popover-in {
154
+ from {
155
+ opacity: 0;
156
+ transform: translate(-50%, -4px) scale(0.95);
157
+ }
158
+ to {
159
+ opacity: 1;
160
+ transform: translate(-50%, 0) scale(1);
161
+ }
162
+ }
163
+
164
+ :global(.dark) .builder-preview-icon-menu {
165
+ border-color: rgba(255, 255, 255, 0.08);
166
+ background-color: rgba(31, 41, 55, 0.95);
167
+ color: #f3f4f6;
168
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
169
+ }
170
+
171
+ .menu-item {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 0.5rem;
175
+ width: 100%;
176
+ border: none;
177
+ background: transparent;
178
+ padding: 6px 12px;
179
+ color: inherit;
180
+ font-size: inherit;
181
+ font-weight: 500;
182
+ text-align: left;
183
+ cursor: pointer;
184
+ border-radius: 6px;
185
+ transition: background-color 0.15s, color 0.15s;
186
+ }
187
+
188
+ .menu-item:hover {
189
+ background-color: #f3f4f6;
190
+ }
191
+
192
+ :global(.dark) .menu-item:hover {
193
+ background-color: #444039;
194
+ }
195
+
196
+ .menu-item-danger {
197
+ color: #dc2626;
198
+ }
199
+
200
+ .menu-item-danger:hover {
201
+ background-color: #fef2f2;
202
+ color: #b91c1c;
203
+ }
204
+
205
+ :global(.dark) .menu-item-danger {
206
+ color: #f87171;
207
+ }
208
+
209
+ :global(.dark) .menu-item-danger:hover {
210
+ background-color: rgba(239, 68, 68, 0.15);
211
+ color: #fca5a5;
212
+ }
213
+ </style>