@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,1299 @@
1
+ <script lang="ts">
2
+ import {
3
+ addBlock as addBlockToState,
4
+ addItem as addItemToState,
5
+ applyFileToPendingEdit,
6
+ clearPendingFileEdit,
7
+ closeReorderModal as closeReorderModalInState,
8
+ createEditorControllerState,
9
+ getActiveReorderContext,
10
+ getBuilderDefinition,
11
+ handleBlockDragStart,
12
+ handleBlockDrop,
13
+ handleCollectionItemDragStart as startCollectionItemDrag,
14
+ handleCollectionItemDrop as dropCollectionItem,
15
+ moveBlock as moveBlockInState,
16
+ moveItem as moveItemInState,
17
+ openReorderModal as openReorderModalInState,
18
+ queueFileEdit as queueFileEditInState,
19
+ removeBlock as removeBlockFromState,
20
+ removeItem as removeItemFromState,
21
+ setBlockError,
22
+ updatePropAtPath
23
+ } from '../editor-controller.js';
24
+ import {
25
+ createInspectorFieldsFromFields,
26
+ getCollectionItemSummary,
27
+ normalizeBuilderPropsForRender,
28
+ parseBrixYamlDocument,
29
+ serializeToBrixYaml,
30
+ serializeToMdsvex,
31
+ getFieldByPath,
32
+ inferBuilderFieldKind,
33
+ type BuilderBlock,
34
+ type BuilderDocument,
35
+ type BuilderPreviewBinding,
36
+ type BuilderRichTextValue
37
+ } from '../core.js';
38
+ import { attachPreviewEditableFields } from '../preview/enhance-editable-fields.js';
39
+ import { describeFieldElement, logFieldEditEvent } from '../preview/field-edit-debug.js';
40
+ import {
41
+ attachPreviewContainer,
42
+ materializeFieldPath,
43
+ resolvePreviewBindingAtPoint,
44
+ type PreviewCollectionOverlay,
45
+ type PreviewOverlay
46
+ } from '../preview-dom.js';
47
+ import type {
48
+ BuilderAppPreviewProps,
49
+ BuilderRenderDefinition,
50
+ PreviewFieldEdit
51
+ } from './contracts.js';
52
+ import BuilderInspector from './BuilderInspector.svelte';
53
+ import BuilderPreviewFrame from './BuilderPreviewFrame.svelte';
54
+ import ComponentPreviewThumbnail from './ComponentPreviewThumbnail.svelte';
55
+ import PageFlowSidebar from './PageFlowSidebar.svelte';
56
+ import { matchesShortcut, SHORTCUTS } from './shortcuts.js';
57
+
58
+ let {
59
+ definitions,
60
+ initialDocument,
61
+ initialBrixYaml,
62
+ chrome = 'standalone',
63
+ onBrixYamlChange,
64
+ pageFlowOpen = $bindable(true),
65
+ inspectorOpen = $bindable(true),
66
+ activeBlockId = $bindable<string | null>(null),
67
+ onpickImage,
68
+ onpickIcon,
69
+ previewMode = $bindable(false),
70
+ viewportSize = $bindable<'desktop' | 'tablet' | 'mobile'>('desktop')
71
+ }: {
72
+ definitions: BuilderRenderDefinition[];
73
+ initialDocument?: BuilderDocument;
74
+ initialBrixYaml?: string;
75
+ chrome?: 'standalone' | 'embedded';
76
+ onBrixYamlChange?: (value: string) => void;
77
+ pageFlowOpen?: boolean;
78
+ inspectorOpen?: boolean;
79
+ activeBlockId?: string | null;
80
+ onpickImage?: (callback: (imageUrl: string) => void) => void;
81
+ onpickIcon?: (callback: (iconSvg: string) => void) => void;
82
+ previewMode?: boolean;
83
+ viewportSize?: 'desktop' | 'tablet' | 'mobile';
84
+ } = $props();
85
+
86
+ let controller = $state<ReturnType<typeof createEditorControllerState> | null>(null);
87
+ let previewOverlays = $state<Record<string, PreviewOverlay[]>>({});
88
+ let previewCollectionOverlays = $state<Record<string, PreviewCollectionOverlay[]>>({});
89
+ let initialized = $state(false);
90
+ let activeFieldEdit = $state<PreviewFieldEdit | null>(null);
91
+ let inserterModal = $state<{ blockId: string; placement: 'before' | 'after' } | null>(null);
92
+ let pageFlowShortcutModifier = $state<'command' | 'control'>('command');
93
+ const previewBlockElements = new Map<string, HTMLElement>();
94
+ const pageFlowShortcutKey = SHORTCUTS.togglePageFlow.key;
95
+ const inspectorShortcutKey = SHORTCUTS.toggleInspector.key;
96
+ const previewShortcutKey = SHORTCUTS.togglePreview.key;
97
+
98
+ let pageFlowWidth = $state(280);
99
+ let inspectorWidth = $state(320);
100
+ let resizing: 'pageFlow' | 'inspector' | null = $state(null);
101
+ let resizeStartX = 0;
102
+ let resizeStartWidth = 0;
103
+
104
+ function startResize(side: 'pageFlow' | 'inspector', event: MouseEvent): void {
105
+ resizing = side;
106
+ resizeStartX = event.clientX;
107
+ resizeStartWidth = side === 'pageFlow' ? pageFlowWidth : inspectorWidth;
108
+ event.preventDefault();
109
+ }
110
+
111
+ function handleResizeMove(event: MouseEvent): void {
112
+ if (!resizing) return;
113
+ const dx = event.clientX - resizeStartX;
114
+ if (resizing === 'pageFlow') {
115
+ pageFlowWidth = Math.max(160, Math.min(480, resizeStartWidth + dx));
116
+ } else {
117
+ inspectorWidth = Math.max(200, Math.min(560, resizeStartWidth - dx));
118
+ }
119
+ }
120
+
121
+ function stopResize(): void {
122
+ resizing = null;
123
+ }
124
+
125
+ $effect(() => {
126
+ if (!controller) {
127
+ const hydratedDocument =
128
+ initialDocument ??
129
+ (initialBrixYaml ? parseBrixYamlDocument(initialBrixYaml, definitions) : undefined);
130
+ controller = createEditorControllerState(definitions, hydratedDocument);
131
+ }
132
+ });
133
+
134
+ $effect(() => {
135
+ if (typeof navigator !== 'undefined') {
136
+ pageFlowShortcutModifier = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
137
+ ? 'command'
138
+ : 'control';
139
+ }
140
+ });
141
+
142
+ $effect(() => {
143
+ const blocks = controller?.document.blocks ?? [];
144
+ if (blocks.length === 0) {
145
+ activeBlockId = null;
146
+ initialized = false;
147
+ return;
148
+ }
149
+
150
+ if (!initialized) {
151
+ activeBlockId = blocks[0]?.id ?? null;
152
+ initialized = true;
153
+ return;
154
+ }
155
+
156
+ if (activeBlockId && !blocks.some((block) => block.id === activeBlockId)) {
157
+ activeBlockId = null;
158
+ }
159
+ });
160
+
161
+ const mdsvexOutput = $derived(
162
+ serializeToMdsvex(
163
+ controller?.document ?? { title: '', description: '', blocks: [] },
164
+ definitions
165
+ )
166
+ );
167
+ const brixYamlOutput = $derived(
168
+ serializeToBrixYaml(
169
+ controller?.document ?? { title: '', description: '', blocks: [] },
170
+ definitions
171
+ )
172
+ );
173
+ const activeReorderContext = $derived.by(() =>
174
+ controller ? getActiveReorderContext(controller, definitions) : null
175
+ );
176
+ const activeBlock = $derived(
177
+ controller?.document.blocks.find((block) => block.id === activeBlockId) ?? null
178
+ );
179
+ const activeDefinition = $derived(
180
+ activeBlock ? getBuilderDefinition(activeBlock.type, definitions) : null
181
+ );
182
+ const inspectorFields = $derived(
183
+ activeDefinition ? createInspectorFieldsFromFields(activeDefinition.fields) : {}
184
+ );
185
+
186
+ function addBlock(type: string): void {
187
+ if (!controller) return;
188
+ const block = addBlockToState(controller, definitions, type);
189
+ activeBlockId = block.id;
190
+ }
191
+
192
+ function addBlockAfter(blockId: string, type: string): void {
193
+ if (!controller) return;
194
+
195
+ const targetIndex = controller.document.blocks.findIndex((block) => block.id === blockId);
196
+ const block = addBlockToState(controller, definitions, type);
197
+
198
+ if (targetIndex !== -1) {
199
+ controller.document.blocks = [
200
+ ...controller.document.blocks.slice(0, targetIndex + 1),
201
+ block,
202
+ ...controller.document.blocks
203
+ .slice(targetIndex + 1)
204
+ .filter((entry) => entry.id !== block.id)
205
+ ];
206
+ }
207
+
208
+ activeBlockId = block.id;
209
+ }
210
+
211
+ function addBlockBefore(blockId: string, type: string): void {
212
+ if (!controller) return;
213
+
214
+ const targetIndex = controller.document.blocks.findIndex((block) => block.id === blockId);
215
+ const block = addBlockToState(controller, definitions, type);
216
+
217
+ if (targetIndex !== -1) {
218
+ controller.document.blocks = [
219
+ ...controller.document.blocks.slice(0, targetIndex),
220
+ block,
221
+ ...controller.document.blocks.slice(targetIndex).filter((entry) => entry.id !== block.id)
222
+ ];
223
+ }
224
+
225
+ activeBlockId = block.id;
226
+ }
227
+
228
+ function removeBlock(blockId: string): void {
229
+ if (!controller) return;
230
+ removeBlockFromState(controller, blockId);
231
+ if (activeFieldEdit?.blockId === blockId) {
232
+ closeFieldEdit();
233
+ }
234
+ if (activeBlockId === blockId) {
235
+ activeBlockId = controller.document.blocks[0]?.id ?? null;
236
+ }
237
+ }
238
+
239
+ function moveBlock(blockId: string, direction: -1 | 1): void {
240
+ if (!controller) return;
241
+ moveBlockInState(controller, blockId, direction);
242
+ }
243
+
244
+ function handleDragStart(blockId: string): void {
245
+ if (!controller) return;
246
+ handleBlockDragStart(controller, blockId);
247
+ }
248
+
249
+ function allowDrop(event: DragEvent): void {
250
+ event.preventDefault();
251
+ }
252
+
253
+ function handleDrop(targetBlockId: string): void {
254
+ if (!controller) return;
255
+ const droppedBlockId = controller.draggedBlockId;
256
+ handleBlockDrop(controller, targetBlockId);
257
+ if (droppedBlockId) {
258
+ selectBlock(droppedBlockId, { forceScroll: true });
259
+ }
260
+ }
261
+
262
+ function togglePageFlow(): void {
263
+ pageFlowOpen = !pageFlowOpen;
264
+ }
265
+
266
+ function toggleInspector(): void {
267
+ inspectorOpen = !inspectorOpen;
268
+ }
269
+
270
+ function togglePreview(): void {
271
+ previewMode = !previewMode;
272
+ if (previewMode) {
273
+ deselectBlock();
274
+ }
275
+ }
276
+
277
+ function handleWindowKeydown(event: KeyboardEvent): void {
278
+ if (matchesShortcut(event, SHORTCUTS.closeModal) && inserterModal) {
279
+ closeInserterModal();
280
+ return;
281
+ }
282
+ if (matchesShortcut(event, SHORTCUTS.togglePageFlow)) {
283
+ event.preventDefault();
284
+ togglePageFlow();
285
+ }
286
+ if (matchesShortcut(event, SHORTCUTS.toggleInspector)) {
287
+ event.preventDefault();
288
+ toggleInspector();
289
+ }
290
+ if (matchesShortcut(event, SHORTCUTS.togglePreview)) {
291
+ event.preventDefault();
292
+ togglePreview();
293
+ }
294
+ }
295
+
296
+ function updateFieldValue(block: BuilderBlock, path: string, value: unknown): void {
297
+ if (!controller) return;
298
+ updatePropAtPath(controller, block, path, value);
299
+ }
300
+
301
+ function queueFileEdit(blockId: string, path: string): void {
302
+ console.log('queueFileEdit called in BuilderApp:', blockId, path, 'onpickImage is:', !!onpickImage);
303
+ if (!controller) return;
304
+
305
+ const block = controller.document.blocks.find((b) => b.id === blockId);
306
+ const definition = block ? getBuilderDefinition(block.type, definitions) : null;
307
+ const field = definition ? getFieldByPath(definition.fields, path) : null;
308
+ const kind = field ? inferBuilderFieldKind(field) : null;
309
+
310
+ if (kind === 'icon') {
311
+ queueFileEditInState(controller, blockId, path);
312
+ if (onpickIcon) {
313
+ onpickIcon((iconSvg) => {
314
+ if (!controller) return;
315
+ const updatedBlock = applyFileToPendingEdit(controller, iconSvg);
316
+ closeFieldEdit();
317
+ if (!updatedBlock) {
318
+ clearPendingFileEdit(controller);
319
+ }
320
+ });
321
+ } else {
322
+ console.warn('onpickIcon is not defined in BuilderApp');
323
+ clearPendingFileEdit(controller);
324
+ }
325
+ return;
326
+ }
327
+
328
+ queueFileEditInState(controller, blockId, path);
329
+ if (onpickImage) {
330
+ console.log('calling onpickImage from BuilderApp...');
331
+ onpickImage((imageUrl) => {
332
+ console.log('onpickImage callback received image URL:', imageUrl);
333
+ if (!controller) return;
334
+ const updatedBlock = applyFileToPendingEdit(controller, imageUrl);
335
+ closeFieldEdit();
336
+ if (!updatedBlock) {
337
+ clearPendingFileEdit(controller);
338
+ }
339
+ });
340
+ } else {
341
+ console.log('onpickImage is not defined in BuilderApp, opening native file picker...');
342
+ openFilePicker();
343
+ }
344
+ }
345
+
346
+ function openFilePicker(): void {
347
+ const input = document.createElement('input');
348
+ input.type = 'file';
349
+ input.accept = 'image/*';
350
+ input.style.display = 'none';
351
+ input.addEventListener('change', handleFileSelection, { once: true });
352
+ document.body.append(input);
353
+ input.click();
354
+ }
355
+
356
+ function scrollPreviewToBlock(blockId: string): void {
357
+ const element = previewBlockElements.get(blockId);
358
+ if (!element) return;
359
+
360
+ requestAnimationFrame(() => {
361
+ element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
362
+ });
363
+ }
364
+
365
+ function selectBlock(blockId: string, options: { forceScroll?: boolean } = {}): void {
366
+ const selectionChanged = activeBlockId !== blockId;
367
+ activeBlockId = blockId;
368
+ if (selectionChanged || options.forceScroll) {
369
+ scrollPreviewToBlock(blockId);
370
+ }
371
+ }
372
+
373
+ function handlePreviewClick(block: BuilderBlock, event: Event): void {
374
+ if (!controller) return;
375
+ selectBlock(block.id);
376
+
377
+ const definition = getBuilderDefinition(block.type, definitions);
378
+ const container = event.currentTarget;
379
+
380
+ if (!isHTMLElement(container)) {
381
+ return;
382
+ }
383
+
384
+ logFieldEditEvent('block-click', 'start', {
385
+ blockId: block.id,
386
+ eventType: event.type,
387
+ clientX: event instanceof MouseEvent ? event.clientX : null,
388
+ clientY: event instanceof MouseEvent ? event.clientY : null,
389
+ target: describeFieldElement(event.target instanceof Element ? event.target : null)
390
+ });
391
+
392
+ const resolvedBinding = resolvePreviewBindingAtPoint<BuilderPreviewBinding>({
393
+ bindings: definition.previewBindings,
394
+ container,
395
+ target: event.target,
396
+ clientX: event instanceof MouseEvent ? event.clientX : undefined,
397
+ clientY: event instanceof MouseEvent ? event.clientY : undefined
398
+ });
399
+ if (!resolvedBinding) {
400
+ logFieldEditEvent('block-click', 'no binding — closing field edit', { blockId: block.id });
401
+ closeFieldEdit();
402
+ return;
403
+ }
404
+
405
+ logFieldEditEvent('block-click', 'binding resolved', {
406
+ blockId: block.id,
407
+ path: resolvedBinding.path,
408
+ bindingType: resolvedBinding.binding.type,
409
+ ...describeFieldElement(resolvedBinding.matchedElement)
410
+ });
411
+
412
+ event.preventDefault();
413
+
414
+ if (resolvedBinding.binding.type === 'image' || resolvedBinding.binding.type === 'icon') {
415
+ const matchedElement = resolvedBinding.matchedElement as HTMLElement;
416
+ const rawPath = matchedElement.getAttribute('data-builder-field');
417
+ const path =
418
+ rawPath != null
419
+ ? (materializeFieldPath(rawPath, container, matchedElement) ?? resolvedBinding.path)
420
+ : resolvedBinding.path;
421
+
422
+ activeFieldEdit = {
423
+ blockId: block.id,
424
+ path,
425
+ caretOffset: null
426
+ };
427
+ return;
428
+ }
429
+
430
+ if (resolvedBinding.binding.type === 'richtext' || resolvedBinding.binding.type === 'text') {
431
+ const matchedElement = resolvedBinding.matchedElement as HTMLElement;
432
+ const rawPath = matchedElement.getAttribute('data-builder-field');
433
+ const path =
434
+ rawPath != null
435
+ ? (materializeFieldPath(rawPath, container, matchedElement) ?? resolvedBinding.path)
436
+ : resolvedBinding.path;
437
+
438
+ activeFieldEdit = {
439
+ blockId: block.id,
440
+ path,
441
+ caretOffset: getClickCaretOffset(matchedElement, event)
442
+ };
443
+ }
444
+ }
445
+
446
+ function handlePreviewKeydown(block: BuilderBlock, event: KeyboardEvent): void {
447
+ if (event.key !== 'Enter' && event.key !== ' ') {
448
+ return;
449
+ }
450
+
451
+ if (isEditableKeyboardTarget(event.target)) {
452
+ return;
453
+ }
454
+
455
+ event.preventDefault();
456
+ handlePreviewClick(block, event);
457
+ }
458
+
459
+ async function handleFileSelection(event: Event): Promise<void> {
460
+ if (!controller) {
461
+ return;
462
+ }
463
+
464
+ const target = event.currentTarget;
465
+ const pending = controller.pendingFileEdit;
466
+ if (!(target instanceof HTMLInputElement) || !pending) {
467
+ return;
468
+ }
469
+
470
+ const [file] = Array.from(target.files ?? []);
471
+ if (!file) {
472
+ clearPendingFileEdit(controller);
473
+ return;
474
+ }
475
+
476
+ try {
477
+ const dataUrl = await readFileAsDataUrl(file);
478
+ const updatedBlock = applyFileToPendingEdit(controller, dataUrl);
479
+ closeFieldEdit();
480
+ if (!updatedBlock) {
481
+ clearPendingFileEdit(controller);
482
+ }
483
+ } catch (error) {
484
+ setBlockError(
485
+ controller,
486
+ pending.blockId,
487
+ error instanceof Error ? error.message : 'Impossibile leggere il file selezionato.'
488
+ );
489
+ clearPendingFileEdit(controller);
490
+ } finally {
491
+ target.value = '';
492
+ target.remove();
493
+ }
494
+ }
495
+
496
+ function readFileAsDataUrl(file: File): Promise<string> {
497
+ return new Promise((resolve, reject) => {
498
+ const reader = new FileReader();
499
+ reader.onload = () => {
500
+ if (typeof reader.result === 'string') {
501
+ resolve(reader.result);
502
+ return;
503
+ }
504
+
505
+ reject(new Error('Il file selezionato non puo essere convertito in data URL.'));
506
+ };
507
+ reader.onerror = () => reject(reader.error ?? new Error('Errore di lettura file.'));
508
+ reader.readAsDataURL(file);
509
+ });
510
+ }
511
+
512
+ function addItem(block: BuilderBlock, collectionPath: string): void {
513
+ if (!controller) return;
514
+ addItemToState(controller, definitions, block, collectionPath);
515
+ }
516
+
517
+ function removeItem(block: BuilderBlock, collectionPath: string, index: number): void {
518
+ if (!controller) return;
519
+ removeItemFromState(controller, definitions, block, collectionPath, index);
520
+ }
521
+
522
+ function moveItem(
523
+ block: BuilderBlock,
524
+ collectionPath: string,
525
+ index: number,
526
+ direction: -1 | 1
527
+ ): void {
528
+ if (!controller) return;
529
+ moveItemInState(controller, definitions, block, collectionPath, index, direction);
530
+ }
531
+
532
+ function openReorderModal(blockId: string, collectionPath: string): void {
533
+ if (!controller) return;
534
+ openReorderModalInState(controller, blockId, collectionPath);
535
+ }
536
+
537
+ function closeReorderModal(): void {
538
+ if (!controller) return;
539
+ closeReorderModalInState(controller);
540
+ }
541
+
542
+ function openInserterModal(blockId: string, placement: 'before' | 'after'): void {
543
+ inserterModal = { blockId, placement };
544
+ }
545
+
546
+ function closeInserterModal(): void {
547
+ inserterModal = null;
548
+ }
549
+
550
+ function insertFromModal(type: string): void {
551
+ if (!inserterModal) return;
552
+ if (inserterModal.placement === 'before') {
553
+ addBlockBefore(inserterModal.blockId, type);
554
+ } else {
555
+ addBlockAfter(inserterModal.blockId, type);
556
+ }
557
+ inserterModal = null;
558
+ }
559
+
560
+ function closeFieldEdit(): void {
561
+ activeFieldEdit = null;
562
+ }
563
+
564
+ function deselectBlock(): void {
565
+ activeFieldEdit = null;
566
+ activeBlockId = null;
567
+ }
568
+
569
+ function updatePreviewRichText(
570
+ block: BuilderBlock,
571
+ path: string,
572
+ value: BuilderRichTextValue
573
+ ): void {
574
+ updateFieldValue(block, path, value);
575
+ }
576
+
577
+ function updatePreviewText(block: BuilderBlock, path: string, value: string): void {
578
+ updateFieldValue(block, path, value);
579
+ }
580
+
581
+ function updateDocumentTitle(value: string): void {
582
+ if (!controller) return;
583
+ controller.document.title = value;
584
+ }
585
+
586
+ function updateDocumentDescription(value: string): void {
587
+ if (!controller) return;
588
+ controller.document.description = value;
589
+ }
590
+
591
+ $effect(() => {
592
+ onBrixYamlChange?.(brixYamlOutput);
593
+ });
594
+
595
+ function getClickCaretOffset(element: Element, event: Event): number | null {
596
+ if (!(event instanceof MouseEvent) || !isHTMLElement(element)) {
597
+ return null;
598
+ }
599
+
600
+ const doc = element.ownerDocument;
601
+ const range =
602
+ doc.caretRangeFromPoint?.(event.clientX, event.clientY) ??
603
+ (() => {
604
+ const pos = doc.caretPositionFromPoint?.(event.clientX, event.clientY);
605
+ if (!pos) {
606
+ return null;
607
+ }
608
+
609
+ const nextRange = doc.createRange();
610
+ nextRange.setStart(pos.offsetNode, pos.offset);
611
+ nextRange.collapse(true);
612
+ return nextRange;
613
+ })();
614
+
615
+ if (!range || !element.contains(range.startContainer)) {
616
+ return null;
617
+ }
618
+
619
+ const preRange = doc.createRange();
620
+ preRange.selectNodeContents(element);
621
+ preRange.setEnd(range.startContainer, range.startOffset);
622
+ return preRange.toString().length;
623
+ }
624
+
625
+ function isElement(value: unknown): value is Element {
626
+ return typeof value === 'object' && value !== null && (value as Node).nodeType === 1;
627
+ }
628
+
629
+ function isHTMLElement(value: unknown): value is HTMLElement {
630
+ if (!isElement(value)) {
631
+ return false;
632
+ }
633
+
634
+ const view = value.ownerDocument.defaultView;
635
+ return view ? value instanceof view.HTMLElement : value instanceof HTMLElement;
636
+ }
637
+
638
+ function isEditableKeyboardTarget(target: EventTarget | null): boolean {
639
+ if (!isElement(target)) {
640
+ return false;
641
+ }
642
+
643
+ return Boolean(
644
+ target.closest(
645
+ '.builder-preview-field-editor, .ProseMirror, .builder-preview-text-editor, input, textarea, [contenteditable="true"]'
646
+ )
647
+ );
648
+ }
649
+
650
+ function handleCollectionItemDragStart(
651
+ blockId: string,
652
+ collectionPath: string,
653
+ index: number
654
+ ): void {
655
+ if (!controller) return;
656
+ startCollectionItemDrag(controller, blockId, collectionPath, index);
657
+ }
658
+
659
+ function handleCollectionItemDrop(targetIndex: number): void {
660
+ if (!controller) return;
661
+ dropCollectionItem(controller, definitions, targetIndex);
662
+ }
663
+
664
+ function previewContainer(
665
+ node: HTMLElement,
666
+ params: {
667
+ block: BuilderBlock;
668
+ definition: BuilderRenderDefinition;
669
+ editing: import('./contracts.js').PreviewEditingContext;
670
+ }
671
+ ): {
672
+ update: (nextParams: {
673
+ block: BuilderBlock;
674
+ definition: BuilderRenderDefinition;
675
+ editing: import('./contracts.js').PreviewEditingContext;
676
+ }) => void;
677
+ destroy: () => void;
678
+ } {
679
+ let blockId = params.block.id;
680
+ previewBlockElements.set(blockId, node);
681
+
682
+ const onOverlaysChange = (blockId: string, overlays: PreviewOverlay[]) => {
683
+ if (overlays.length === 0) {
684
+ delete previewOverlays[blockId];
685
+ return;
686
+ }
687
+
688
+ previewOverlays[blockId] = overlays;
689
+ };
690
+ const onCollectionOverlaysChange = (blockId: string, overlays: PreviewCollectionOverlay[]) => {
691
+ if (overlays.length === 0) {
692
+ delete previewCollectionOverlays[blockId];
693
+ return;
694
+ }
695
+
696
+ previewCollectionOverlays[blockId] = overlays;
697
+ };
698
+ const editableFields = attachPreviewEditableFields(node, {
699
+ block: params.block,
700
+ definition: params.definition,
701
+ previewProps: params.editing.previewProps,
702
+ active: params.editing.active,
703
+ focusPath: params.editing.focusPath,
704
+ caretOffset: params.editing.caretOffset,
705
+ onUpdateRichText: (path, value) => updatePreviewRichText(params.block, path, value),
706
+ onUpdateText: (path, value) => updatePreviewText(params.block, path, value),
707
+ onQueueFileEdit: (path) => queueFileEdit(params.block.id, path),
708
+ onCloseFieldEdit: closeFieldEdit,
709
+ onFocusField: (path, caretOffset) => {
710
+ activeFieldEdit = { blockId: params.block.id, path, caretOffset };
711
+ selectBlock(params.block.id, { forceScroll: false });
712
+ }
713
+ });
714
+ const action = attachPreviewContainer(node, {
715
+ block: params.block,
716
+ definition: params.definition,
717
+ onOverlaysChange,
718
+ onCollectionOverlaysChange
719
+ });
720
+
721
+ return {
722
+ update(nextParams) {
723
+ if (blockId !== nextParams.block.id) {
724
+ previewBlockElements.delete(blockId);
725
+ blockId = nextParams.block.id;
726
+ previewBlockElements.set(blockId, node);
727
+ }
728
+
729
+ editableFields.update({
730
+ block: nextParams.block,
731
+ definition: nextParams.definition,
732
+ previewProps: nextParams.editing.previewProps,
733
+ active: nextParams.editing.active,
734
+ focusPath: nextParams.editing.focusPath,
735
+ caretOffset: nextParams.editing.caretOffset,
736
+ onUpdateRichText: (path, value) => updatePreviewRichText(nextParams.block, path, value),
737
+ onUpdateText: (path, value) => updatePreviewText(nextParams.block, path, value),
738
+ onQueueFileEdit: (path) => queueFileEdit(nextParams.block.id, path),
739
+ onCloseFieldEdit: closeFieldEdit,
740
+ onFocusField: (path, caretOffset) => {
741
+ activeFieldEdit = { blockId: nextParams.block.id, path, caretOffset };
742
+ selectBlock(nextParams.block.id, { forceScroll: false });
743
+ }
744
+ });
745
+ action.update({
746
+ block: nextParams.block,
747
+ definition: nextParams.definition,
748
+ onOverlaysChange,
749
+ onCollectionOverlaysChange
750
+ });
751
+ },
752
+ destroy() {
753
+ previewBlockElements.delete(blockId);
754
+ editableFields.destroy();
755
+ action.destroy();
756
+ }
757
+ };
758
+ }
759
+
760
+ async function copyMdsvex(): Promise<void> {
761
+ if (!controller) return;
762
+ await navigator.clipboard.writeText(mdsvexOutput);
763
+ controller.copied = true;
764
+ setTimeout(() => {
765
+ if (controller) {
766
+ controller.copied = false;
767
+ }
768
+ }, 1500);
769
+ }
770
+
771
+ const previewProps = $derived<BuilderAppPreviewProps>({
772
+ definitions,
773
+ blocks: controller?.document.blocks ?? [],
774
+ propsErrors: controller?.propsErrors ?? {},
775
+ previewOverlays,
776
+ previewCollectionOverlays,
777
+ activeBlockId,
778
+ activeFieldEdit,
779
+ previewContainer,
780
+ onPreviewClick: handlePreviewClick,
781
+ onPreviewKeydown: handlePreviewKeydown,
782
+ onSelectBlock: selectBlock,
783
+ onCloseFieldEdit: closeFieldEdit,
784
+ onUpdateRichText: updatePreviewRichText,
785
+ onUpdateText: updatePreviewText,
786
+ onQueueFileEdit: queueFileEdit,
787
+ onAddBlockBefore: addBlockBefore,
788
+ onAddBlockAfter: addBlockAfter,
789
+ onAddItem: addItem,
790
+ onRemoveItem: removeItem,
791
+ onMoveItem: moveItem,
792
+ onOpenReorderModal: openReorderModal,
793
+ onOpenInserterModal: openInserterModal,
794
+ onDeselectBlock: deselectBlock,
795
+ previewMode,
796
+ viewportSize
797
+ });
798
+ </script>
799
+
800
+ <svelte:window
801
+ onkeydown={handleWindowKeydown}
802
+ onmousemove={handleResizeMove}
803
+ onmouseup={stopResize}
804
+ />
805
+
806
+ <svelte:head>
807
+ <title>Brixter Builder</title>
808
+ <meta
809
+ name="description"
810
+ content="Brixter visual editor for briks, pages, and optional mdsvex export."
811
+ />
812
+ </svelte:head>
813
+
814
+ <div
815
+ class={chrome === 'standalone'
816
+ ? 'builder-app flex h-screen flex-col overflow-hidden bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100'
817
+ : 'builder-app flex h-full min-h-0 flex-col overflow-hidden bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100'}
818
+ >
819
+ {#if chrome === 'standalone'}
820
+ <header
821
+ class="flex h-[60px] shrink-0 items-center justify-between border-b border-gray-200 bg-white px-3 dark:border-gray-700 dark:bg-gray-900"
822
+ onclick={(event) => { if (!(event.target as Element).closest('button, input, a')) deselectBlock(); }}
823
+ >
824
+ <div class="flex items-center gap-2">
825
+ <div
826
+ class="flex h-9 w-9 items-center justify-center bg-gray-900 text-sm font-semibold text-white dark:bg-gray-100 dark:text-gray-900"
827
+ >
828
+ B
829
+ </div>
830
+ <button
831
+ type="button"
832
+ class={pageFlowOpen
833
+ ? 'btn-brutal-icon group relative flex h-9 w-9 items-center justify-center'
834
+ : 'group relative flex h-9 w-9 items-center justify-center border border-gray-300 bg-white text-gray-900 transition-colors hover:border-accent hover:bg-accent-hover hover:text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-accent dark:hover:bg-accent-hover dark:hover:text-gray-900'}
835
+ aria-label={pageFlowOpen ? 'Chiudi Page flow' : 'Apri Page flow'}
836
+ aria-pressed={pageFlowOpen}
837
+ onclick={togglePageFlow}
838
+ >
839
+ <svg class="h-4 w-4" viewBox="0 0 16 16" aria-hidden="true">
840
+ <path
841
+ d="M3 3.5h10v1.25H3V3.5Zm0 3.875h10v1.25H3v-1.25Zm0 3.875h10v1.25H3v-1.25Z"
842
+ fill="currentColor"
843
+ />
844
+ </svg>
845
+ <span
846
+ class="pointer-events-none absolute top-full left-0 z-50 mt-2 flex flex-col items-start gap-1.5 border border-gray-300 bg-white px-3 py-2 text-xs whitespace-nowrap text-gray-900 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
847
+ >
848
+ <span class="font-semibold">Page flow</span>
849
+ <span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
850
+ <span
851
+ class="inline-flex h-5 items-center gap-1 border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
852
+ >
853
+ {#if pageFlowShortcutModifier === 'command'}
854
+ <svg class="h-3 w-3" viewBox="0 0 16 16" aria-hidden="true">
855
+ <path
856
+ d="M5 2.25A2.75 2.75 0 0 0 2.25 5v.75H5V2.25Zm1.25 3.5h3.5v-3.5h-3.5v3.5Zm4.75 0h2.75V5A2.75 2.75 0 0 0 11 2.25h-.75v3.5ZM9.75 7h-3.5v2h3.5V7ZM5 7H2.25v2H5V7Zm5.25 0v2h3.5V7h-3.5ZM5 10.25H2.25V11A2.75 2.75 0 0 0 5 13.75h.75v-3.5H5Zm1.25 0v3.5h3.5v-3.5h-3.5Zm4 0v3.5H11A2.75 2.75 0 0 0 13.75 11v-.75h-3.5Z"
857
+ fill="currentColor"
858
+ />
859
+ </svg>
860
+ {:else}
861
+ Ctrl
862
+ {/if}
863
+ </span>
864
+ <span class="text-[11px] font-semibold text-gray-400 dark:text-gray-500">+</span>
865
+ <span
866
+ class="inline-flex h-5 items-center border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
867
+ >
868
+ {pageFlowShortcutKey}
869
+ </span>
870
+ </span>
871
+ </span>
872
+ </button>
873
+ <button
874
+ type="button"
875
+ class="btn-brutal-icon flex h-9 w-9 items-center justify-center text-xl leading-none"
876
+ onclick={() => definitions[0] && addBlock(definitions[0].type)}
877
+ aria-label="Aggiungi brik"
878
+ >
879
+ +
880
+ </button>
881
+ <div class="ml-2 h-6 w-px bg-gray-200 dark:bg-gray-700"></div>
882
+ <p class="text-sm font-medium">Brixter Builder</p>
883
+ </div>
884
+
885
+ <!-- Center Device Selection -->
886
+ <div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center gap-0.5 p-0.5 bg-gray-100 dark:bg-gray-800/80 z-10 border-2 border-gray-200/50 dark:border-gray-700/50 shadow-inner">
887
+ <button
888
+ type="button"
889
+ class="flex h-8 w-8 items-center justify-center cursor-pointer transition-all duration-150 {viewportSize === 'desktop'
890
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white font-medium'
891
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 bg-transparent'}"
892
+ onclick={() => viewportSize = 'desktop'}
893
+ title="Desktop (100%)"
894
+ >
895
+ <svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>
896
+ </button>
897
+ <button
898
+ type="button"
899
+ class="flex h-8 w-8 items-center justify-center cursor-pointer transition-all duration-150 {viewportSize === 'tablet'
900
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white font-medium'
901
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 bg-transparent'}"
902
+ onclick={() => viewportSize = 'tablet'}
903
+ title="Tablet (768px)"
904
+ >
905
+ <svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2" ry="2"/><line x1="18" x2="18.01" y1="12" y2="12"/></svg>
906
+ </button>
907
+ <button
908
+ type="button"
909
+ class="flex h-8 w-8 items-center justify-center cursor-pointer transition-all duration-150 {viewportSize === 'mobile'
910
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white font-medium'
911
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 bg-transparent'}"
912
+ onclick={() => viewportSize = 'mobile'}
913
+ title="Mobile (375px)"
914
+ >
915
+ <svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
916
+ </button>
917
+ </div>
918
+
919
+ <div class="flex items-center gap-2">
920
+ <!-- Mode Selector -->
921
+ <div class="group relative inline-flex items-center gap-0.5 p-0.5 bg-gray-100 dark:bg-gray-800/80 border-2 border-gray-200/50 dark:border-gray-700/50 shadow-inner">
922
+ <button
923
+ type="button"
924
+ class="inline-flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs font-medium transition-all duration-150 {!previewMode
925
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
926
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 bg-transparent'}"
927
+ onclick={() => previewMode = false}
928
+ >
929
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
930
+ <span>Editor</span>
931
+ </button>
932
+ <button
933
+ type="button"
934
+ class="inline-flex h-8 cursor-pointer items-center gap-1.5 px-3 text-xs font-medium transition-all duration-150 {previewMode
935
+ ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
936
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 bg-transparent'}"
937
+ onclick={() => {
938
+ previewMode = true;
939
+ deselectBlock();
940
+ }}
941
+ >
942
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0z"/><circle cx="12" cy="12" r="3"/></svg>
943
+ <span>Preview</span>
944
+ </button>
945
+ <span
946
+ class="pointer-events-none absolute top-full right-0 z-50 mt-2 flex flex-col items-start gap-1.5 border border-gray-300 bg-white px-3 py-2 text-xs whitespace-nowrap text-gray-900 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
947
+ >
948
+ <span class="font-semibold">Preview</span>
949
+ <span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
950
+ <span
951
+ class="inline-flex h-5 items-center gap-1 border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
952
+ >
953
+ {#if pageFlowShortcutModifier === 'command'}
954
+ <svg class="h-3 w-3" viewBox="0 0 16 16" aria-hidden="true">
955
+ <path
956
+ d="M5 2.25A2.75 2.75 0 0 0 2.25 5v.75H5V2.25Zm1.25 3.5h3.5v-3.5h-3.5v3.5Zm4.75 0h2.75V5A2.75 2.75 0 0 0 11 2.25h-.75v3.5ZM9.75 7h-3.5v2h3.5V7ZM5 7H2.25v2H5V7Zm5.25 0v2h3.5V7h-3.5ZM5 10.25H2.25V11A2.75 2.75 0 0 0 5 13.75h.75v-3.5H5Zm1.25 0v3.5h3.5v-3.5h-3.5Zm4 0v3.5H11A2.75 2.75 0 0 0 13.75 11v-.75h-3.5Z"
957
+ fill="currentColor"
958
+ />
959
+ </svg>
960
+ {:else}
961
+ Ctrl
962
+ {/if}
963
+ </span>
964
+ <span class="text-[11px] font-semibold text-gray-400 dark:text-gray-500">+</span>
965
+ <span
966
+ class="inline-flex h-5 items-center border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium uppercase text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
967
+ >
968
+ {previewShortcutKey}
969
+ </span>
970
+ </span>
971
+ </span>
972
+ </div>
973
+
974
+ <button
975
+ type="button"
976
+ class="btn-brutal-flat cursor-pointer px-3 py-1.5 text-xs font-medium"
977
+ onclick={copyMdsvex}
978
+ >
979
+ {controller?.copied ? 'Copiato' : 'Copia export'}
980
+ </button>
981
+ <button
982
+ type="button"
983
+ class={inspectorOpen
984
+ ? 'btn-brutal-icon group relative flex h-9 w-9 items-center justify-center'
985
+ : 'group relative flex h-9 w-9 items-center justify-center border border-gray-300 bg-white text-gray-900 transition-colors hover:border-accent hover:bg-accent-hover hover:text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-accent dark:hover:bg-accent-hover dark:hover:text-gray-900'}
986
+ aria-label={inspectorOpen ? 'Chiudi Inspector' : 'Apri Inspector'}
987
+ aria-pressed={inspectorOpen}
988
+ onclick={toggleInspector}
989
+ >
990
+ <svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
991
+ <rect x="2" y="2" width="12" height="12" rx="1.5" stroke="currentColor" stroke-width="1.25"/>
992
+ <path d="M10.5 2.5v11" stroke="currentColor" stroke-width="1.25"/>
993
+ </svg>
994
+ <span
995
+ class="pointer-events-none absolute top-full right-0 z-50 mt-2 flex flex-col items-start gap-1.5 border border-gray-300 bg-white px-3 py-2 text-xs whitespace-nowrap text-gray-900 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
996
+ >
997
+ <span class="font-semibold">Inspector</span>
998
+ <span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
999
+ <span
1000
+ class="inline-flex h-5 items-center gap-1 border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
1001
+ >
1002
+ {#if pageFlowShortcutModifier === 'command'}
1003
+ <svg class="h-3 w-3" viewBox="0 0 16 16" aria-hidden="true">
1004
+ <path
1005
+ d="M5 2.25A2.75 2.75 0 0 0 2.25 5v.75H5V2.25Zm1.25 3.5h3.5v-3.5h-3.5v3.5Zm4.75 0h2.75V5A2.75 2.75 0 0 0 11 2.25h-.75v3.5ZM9.75 7h-3.5v2h3.5V7ZM5 7H2.25v2H5V7Zm5.25 0v2h3.5V7h-3.5ZM5 10.25H2.25V11A2.75 2.75 0 0 0 5 13.75h.75v-3.5H5Zm1.25 0v3.5h3.5v-3.5h-3.5Zm4 0v3.5H11A2.75 2.75 0 0 0 13.75 11v-.75h-3.5Z"
1006
+ fill="currentColor"
1007
+ />
1008
+ </svg>
1009
+ {:else}
1010
+ Ctrl
1011
+ {/if}
1012
+ </span>
1013
+ <span class="text-[11px] font-semibold text-gray-400 dark:text-gray-500">+</span>
1014
+ <span class="inline-flex h-5 items-center border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200">Shift</span>
1015
+ <span class="text-[11px] font-semibold text-gray-400 dark:text-gray-500">+</span>
1016
+ <span
1017
+ class="inline-flex h-5 items-center border-2 border-gray-200 bg-gray-50 px-1.5 text-[11px] font-medium text-gray-700 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-200"
1018
+ >
1019
+ {inspectorShortcutKey}
1020
+ </span>
1021
+ </span>
1022
+ </span>
1023
+ </button>
1024
+ </div>
1025
+ </header>
1026
+ {/if}
1027
+
1028
+ {#if resizing}
1029
+ <div class="fixed inset-0 z-50 cursor-col-resize" />
1030
+ {/if}
1031
+
1032
+ <div class="flex min-h-0 flex-1 overflow-hidden">
1033
+ {#if pageFlowOpen && !previewMode}
1034
+ <div class="relative shrink-0" style="width: {pageFlowWidth}px">
1035
+ <PageFlowSidebar
1036
+ blocks={controller?.document.blocks ?? []}
1037
+ {activeBlockId}
1038
+ onSelectBlock={selectBlock}
1039
+ onDeselectBlock={deselectBlock}
1040
+ onMoveBlock={moveBlock}
1041
+ onRemoveBlock={removeBlock}
1042
+ onDragStart={handleDragStart}
1043
+ onAllowDrop={allowDrop}
1044
+ onDrop={handleDrop}
1045
+ />
1046
+ <div
1047
+ class="absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-[#FDE047]/30 dark:hover:bg-[#FACC15]/30"
1048
+ onmousedown={(e) => startResize('pageFlow', e)}
1049
+ />
1050
+ </div>
1051
+ {/if}
1052
+
1053
+ <main class="min-w-0 flex-1 overflow-hidden bg-white dark:bg-[#12100d]">
1054
+ <div class="h-full min-h-0 w-full">
1055
+ <BuilderPreviewFrame {...previewProps} onKeydown={handleWindowKeydown} />
1056
+ </div>
1057
+ </main>
1058
+
1059
+ {#if inspectorOpen && !previewMode}
1060
+ <div class="relative shrink-0" style="width: {inspectorWidth}px">
1061
+ <BuilderInspector
1062
+ title={controller?.document.title ?? ''}
1063
+ description={controller?.document.description ?? ''}
1064
+ {activeBlock}
1065
+ {activeDefinition}
1066
+ {inspectorFields}
1067
+ propsError={activeBlock ? (controller?.propsErrors[activeBlock.id] ?? null) : null}
1068
+ {mdsvexOutput}
1069
+ copied={controller?.copied ?? false}
1070
+ onTitleChange={updateDocumentTitle}
1071
+ onDescriptionChange={updateDocumentDescription}
1072
+ onFieldChange={updateFieldValue}
1073
+ onQueueFileEdit={queueFileEdit}
1074
+ onAddItem={addItem}
1075
+ onRemoveItem={removeItem}
1076
+ onMoveItem={moveItem}
1077
+ onCopyMdsvex={copyMdsvex}
1078
+ onDeselectBlock={deselectBlock}
1079
+ />
1080
+ <div
1081
+ class="absolute top-0 left-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-[#FDE047]/30 dark:hover:bg-[#FACC15]/30"
1082
+ onmousedown={(e) => startResize('inspector', e)}
1083
+ />
1084
+ </div>
1085
+ {/if}
1086
+ </div>
1087
+ </div>
1088
+
1089
+ {#if inserterModal}
1090
+ <div class="fixed inset-0 z-50 flex items-center justify-center p-6">
1091
+ <button
1092
+ type="button"
1093
+ class="absolute inset-0 bg-black/45"
1094
+ aria-label="Close component selector"
1095
+ onclick={closeInserterModal}
1096
+ ></button>
1097
+ <div
1098
+ class="relative flex max-h-[min(760px,calc(100vh-3rem))] w-full max-w-5xl flex-col border border-gray-300 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
1099
+ role="dialog"
1100
+ aria-modal="true"
1101
+ aria-label="Choose component to add"
1102
+ tabindex="-1"
1103
+ onclick={(event) => event.stopPropagation()}
1104
+ onkeydown={(event) => {
1105
+ if (event.key === 'Escape') {
1106
+ closeInserterModal();
1107
+ }
1108
+ }}
1109
+ >
1110
+ <div
1111
+ class="flex shrink-0 items-start justify-between gap-4 border-b border-gray-200 p-5 dark:border-gray-700"
1112
+ >
1113
+ <div>
1114
+ <h2 class="text-heading text-lg font-semibold">Add component</h2>
1115
+ <p class="text-muted mt-1 text-sm">
1116
+ Choose the component to insert {inserterModal.placement === 'before' ? 'before' : 'after'} this
1117
+ section.
1118
+ </p>
1119
+ </div>
1120
+ <button
1121
+ type="button"
1122
+ class="flex h-9 w-9 items-center justify-center border border-gray-300 text-xl leading-none text-gray-700 transition-colors hover:border-accent hover:bg-accent-hover hover:text-gray-900 dark:border-gray-700 dark:text-gray-200 dark:hover:border-accent dark:hover:bg-accent-hover"
1123
+ aria-label="Close"
1124
+ onclick={closeInserterModal}
1125
+ >
1126
+ ×
1127
+ </button>
1128
+ </div>
1129
+
1130
+ <div class="min-h-0 flex-1 overflow-y-auto p-5">
1131
+ <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
1132
+ {#each definitions as definition (definition.type)}
1133
+ <button
1134
+ type="button"
1135
+ class="group overflow-hidden border border-gray-300 bg-white text-left transition-colors hover:border-[#FDE047] hover:bg-yellow-50/50 focus:border-[#FDE047] focus:ring-2 focus:ring-[#FDE047]/30 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:hover:border-[#FACC15] dark:hover:bg-yellow-950/40 dark:focus:border-[#FACC15] dark:focus:ring-[#FACC15]/30"
1136
+ onclick={() => insertFromModal(definition.type)}
1137
+ >
1138
+ <ComponentPreviewThumbnail {definition} />
1139
+ <div class="border-t border-gray-200 p-4 dark:border-gray-700">
1140
+ <p class="text-heading text-sm font-semibold">{definition.type}</p>
1141
+ <p class="text-muted mt-1 line-clamp-2 text-xs leading-5">
1142
+ {definition.description}
1143
+ </p>
1144
+ </div>
1145
+ </button>
1146
+ {/each}
1147
+ </div>
1148
+ </div>
1149
+ </div>
1150
+ </div>
1151
+ {/if}
1152
+
1153
+ {#if activeReorderContext}
1154
+ <div class="fixed inset-0 z-50 flex items-center justify-center p-6">
1155
+ <button
1156
+ type="button"
1157
+ class="absolute inset-0 bg-black/40"
1158
+ aria-label="Chiudi modale riordino"
1159
+ onclick={closeReorderModal}
1160
+ ></button>
1161
+ <div
1162
+ class="relative w-full max-w-3xl border border-gray-300 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900"
1163
+ role="dialog"
1164
+ aria-modal="true"
1165
+ aria-label={`Riordina ${activeReorderContext.collection.label}`}
1166
+ tabindex="0"
1167
+ >
1168
+ <div class="flex flex-wrap items-start justify-between gap-4">
1169
+ <div>
1170
+ <h2 class="font-display text-heading text-2xl">
1171
+ Riordina {activeReorderContext.collection.label}
1172
+ </h2>
1173
+ <p class="text-muted mt-1 text-sm">
1174
+ Trascina gli elementi in una lista lineare per aggiornare l'ordine della collection.
1175
+ </p>
1176
+ </div>
1177
+
1178
+ <button
1179
+ type="button"
1180
+ class="border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-700"
1181
+ onclick={closeReorderModal}
1182
+ >
1183
+ Chiudi
1184
+ </button>
1185
+ </div>
1186
+
1187
+ <div class="mt-6 space-y-3" role="list">
1188
+ {#each activeReorderContext.items as item, itemIndex}
1189
+ <div
1190
+ class="flex cursor-move flex-wrap items-center justify-between gap-4 border-2 border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800"
1191
+ role="listitem"
1192
+ draggable={true}
1193
+ ondragstart={() =>
1194
+ handleCollectionItemDragStart(
1195
+ activeReorderContext.block.id,
1196
+ activeReorderContext.collection.path,
1197
+ itemIndex
1198
+ )}
1199
+ ondragover={allowDrop}
1200
+ ondrop={() => handleCollectionItemDrop(itemIndex)}
1201
+ >
1202
+ <div class="flex min-w-0 items-center gap-4">
1203
+ <div
1204
+ class="text-muted flex h-12 w-12 items-center justify-center border border-gray-300 bg-white text-xs font-semibold dark:border-gray-700 dark:bg-gray-900"
1205
+ >
1206
+ {itemIndex + 1}
1207
+ </div>
1208
+
1209
+ <div class="min-w-0">
1210
+ <p class="text-heading truncate text-sm font-medium">
1211
+ {getCollectionItemSummary(item, activeReorderContext.collection, itemIndex)}
1212
+ </p>
1213
+ <p class="text-muted truncate text-xs">
1214
+ {activeReorderContext.collection.path}[{itemIndex}]
1215
+ </p>
1216
+ </div>
1217
+ </div>
1218
+
1219
+ <div class="flex flex-wrap items-center gap-2 text-sm">
1220
+ <button
1221
+ type="button"
1222
+ class="border border-gray-300 px-3 py-1.5 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700"
1223
+ onclick={() =>
1224
+ moveItem(
1225
+ activeReorderContext.block,
1226
+ activeReorderContext.collection.path,
1227
+ itemIndex,
1228
+ -1
1229
+ )}
1230
+ >
1231
+ Su
1232
+ </button>
1233
+ <button
1234
+ type="button"
1235
+ class="border border-gray-300 px-3 py-1.5 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700"
1236
+ onclick={() =>
1237
+ moveItem(
1238
+ activeReorderContext.block,
1239
+ activeReorderContext.collection.path,
1240
+ itemIndex,
1241
+ 1
1242
+ )}
1243
+ >
1244
+ Giu
1245
+ </button>
1246
+ </div>
1247
+ </div>
1248
+ {/each}
1249
+ </div>
1250
+ </div>
1251
+ </div>
1252
+ {/if}
1253
+
1254
+ <style>
1255
+ :global(.builder-app *) {
1256
+ scrollbar-color: #cbd5e1 transparent;
1257
+ scrollbar-width: thin;
1258
+ }
1259
+
1260
+ :global(.builder-app *::-webkit-scrollbar) {
1261
+ width: 10px;
1262
+ height: 10px;
1263
+ }
1264
+
1265
+ :global(.builder-app *::-webkit-scrollbar-track) {
1266
+ background: transparent;
1267
+ }
1268
+
1269
+ :global(.builder-app *::-webkit-scrollbar-thumb) {
1270
+ min-height: 40px;
1271
+ border: 3px solid transparent;
1272
+ border-radius: 999px;
1273
+ background: #cbd5e1;
1274
+ background-clip: padding-box;
1275
+ }
1276
+
1277
+ :global(.builder-app *::-webkit-scrollbar-thumb:hover) {
1278
+ background: #fde047;
1279
+ background-clip: padding-box;
1280
+ }
1281
+
1282
+ :global(.builder-app *::-webkit-scrollbar-corner) {
1283
+ background: transparent;
1284
+ }
1285
+
1286
+ :global(.dark .builder-app *) {
1287
+ scrollbar-color: #475569 transparent;
1288
+ }
1289
+
1290
+ :global(.dark .builder-app *::-webkit-scrollbar-thumb) {
1291
+ background: #475569;
1292
+ background-clip: padding-box;
1293
+ }
1294
+
1295
+ :global(.dark .builder-app *::-webkit-scrollbar-thumb:hover) {
1296
+ background: #facc15;
1297
+ background-clip: padding-box;
1298
+ }
1299
+ </style>