@kubex/zinc 1.1.95 → 1.1.96

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.
@@ -1,36 +1,13 @@
1
+ import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
1
2
  import {
2
- cloneWithNewIds,
3
- containerCells,
4
- containerColumns,
5
- containerDepth,
6
- containerGrow,
7
- containerHeight,
8
- containerWidths,
9
- extractSection,
10
- findSection,
11
- insertIntoCell,
12
- normaliseCells,
13
- normaliseGrowth,
14
- normaliseSections,
15
- patchSection,
16
- recolumnCells,
17
- trimTrailingEmptyCells,
18
- } from './page-tree';
19
- import { type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS } from 'lit';
20
- import {
21
- DEFAULT_WIDTHS,
22
- defaultLayout,
23
3
  emptyPageState,
24
4
  generateSectionId,
25
- isContainer,
26
- MAX_COLUMNS,
27
- MAX_CONTAINER_LEVELS,
28
- MAX_WIDTH,
29
5
  PAGE_SECTION_MIME,
30
6
  PAGE_TYPE_MIME,
31
7
  type PageSection,
32
8
  type PageSectionType,
33
9
  type PageState,
10
+ sectionChildren,
34
11
  sectionSummary,
35
12
  } from './page.types';
36
13
  import { FormControlController, validValidityState } from '../../internal/form';
@@ -45,11 +22,14 @@ import ZnIcon from '../icon';
45
22
  import ZnInput from '../input';
46
23
  import ZnPagePaletteItem from './modules/page-palette-item';
47
24
  import ZnPageSectionCard from './modules/page-section-card';
48
- import ZnToggle from '../toggle';
49
25
 
50
26
  import styles from './page-builder.scss';
51
27
 
52
28
  const HISTORY_LIMIT = 50;
29
+ /** Sections beyond this are dropped (with a warning) when external state is applied. */
30
+ const MAX_SECTIONS = 500;
31
+ /** Per-container children beyond this are dropped when external state is applied. */
32
+ const MAX_CHILDREN = 24;
53
33
  /** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
54
34
  const NARROW_WIDTH = 768;
55
35
 
@@ -68,14 +48,6 @@ function timeAgo(ms: number): string {
68
48
  return `${Math.floor(h / 24)}d ago`;
69
49
  }
70
50
 
71
- /** Where a section lives inside a container cell. */
72
- interface CellOwner {
73
- containerId: string;
74
- cellIndex: number;
75
- index: number;
76
- columns: number;
77
- }
78
-
79
51
  /**
80
52
  * @summary A config-driven page composer: a palette of predefined section types, a linear
81
53
  * canvas of section cards, and an inspector for editing each section's content.
@@ -93,14 +65,10 @@ interface CellOwner {
93
65
  * @event zn-page-selection-change - Emitted when the selected section changes. `event.detail.sectionId`.
94
66
  *
95
67
  * @slot config - `<template type="…">` declarations; never displayed. Each template's attributes
96
- * (type, label, icon, icon-library, color, category, description, container, columns, widths,
97
- * grow, slots, accepts) declare a palette entry and its content declares the inspector form for
98
- * that type. `container` makes the type a container whose editor-configurable layout starts from
99
- * `columns` (seeds equal widths) or `widths` (explicit weights, wins over `columns`); `grow` seeds
100
- * a growable instance. `accepts` is a comma-separated list of the type keys its cells allow —
101
- * omitted on a `container` type, any type is allowed subject to the nesting cap. `slots` is
102
- * @deprecated: it declares a fixed-slot container of `DEFAULT_WIDTHS` columns pinned to that many
103
- * cells, and keeps the old any-non-container-type rule when `accepts` is omitted.
68
+ * (type, label, icon, icon-library, color, category, description, slots, accepts) declare a
69
+ * palette entry and its content declares the inspector form for that type. `slots` makes the
70
+ * section a container with that many child slots; `accepts` is a comma-separated list of the
71
+ * type keys its slots allow.
104
72
  * @slot header-left - Actions shown on the left of the header bar.
105
73
  * @slot header-right - Actions shown on the right of the header bar.
106
74
  *
@@ -121,7 +89,6 @@ export default class ZnPageBuilder extends ZincElement {
121
89
  'zn-input': ZnInput,
122
90
  'zn-page-palette-item': ZnPagePaletteItem,
123
91
  'zn-page-section-card': ZnPageSectionCard,
124
- 'zn-toggle': ZnToggle,
125
92
  };
126
93
 
127
94
  private readonly formControlController = new FormControlController(this);
@@ -180,10 +147,10 @@ export default class ZnPageBuilder extends ZincElement {
180
147
  /** Index of the drop zone whose "+" type picker is open, if any. */
181
148
  @state() private _pickerIndex: number | null = null;
182
149
  @state() private _dragOverIndex: number | null = null;
183
- /** The cell a drag is currently over, if any. */
184
- @state() private _cellDragOver: { containerId: string; cellIndex: number; insertIndex: number } | null = null;
185
- /** The cell whose "+" type picker is open, if any. */
186
- @state() private _cellPicker: { containerId: string; cellIndex: number } | null = null;
150
+ /** The container slot a drag is currently over, if any. */
151
+ @state() private _slotDragOver: { containerId: string; index: number } | null = null;
152
+ /** The container slot whose "+" type picker is open, if any. */
153
+ @state() private _slotPicker: { containerId: string; index: number } | null = null;
187
154
  /** The stamped config form for the selected section; rebuilt on selection change. */
188
155
  @state() private _form: HTMLDivElement | null = null;
189
156
 
@@ -441,18 +408,6 @@ export default class ZnPageBuilder extends ZincElement {
441
408
  const type = el.getAttribute('type');
442
409
  if (!type) return null;
443
410
  const slots = parseInt(el.getAttribute('slots') ?? '', 10);
444
- // `container` is the flag; `columns`/`widths`/`grow` only apply when it's present, so a
445
- // legacy `slots`-only declaration is untouched by the new fields.
446
- const container = el.hasAttribute('container');
447
- let defaultWidths: number[] | undefined;
448
- if (container) {
449
- const widths = (el.getAttribute('widths') ?? '')
450
- .trim().split(/[\s,]+/).filter(Boolean).map(Number).filter(n => !Number.isNaN(n));
451
- const columns = parseInt(el.getAttribute('columns') ?? '', 10);
452
- defaultWidths = widths.length
453
- ? widths
454
- : columns > 0 ? Array.from({ length: columns }, () => 1) : [...DEFAULT_WIDTHS];
455
- }
456
411
  return {
457
412
  type,
458
413
  label: el.getAttribute('label') ?? type,
@@ -464,9 +419,6 @@ export default class ZnPageBuilder extends ZincElement {
464
419
  configTemplate: el,
465
420
  slots: slots > 0 ? slots : undefined,
466
421
  accepts: el.getAttribute('accepts')?.split(',').map(s => s.trim()).filter(Boolean),
467
- container: container ? true : undefined,
468
- defaultWidths,
469
- defaultGrow: container ? el.hasAttribute('grow') : undefined,
470
422
  };
471
423
  }
472
424
 
@@ -484,15 +436,41 @@ export default class ZnPageBuilder extends ZincElement {
484
436
 
485
437
  /** Normalises and installs an externally provided state; resets selection. */
486
438
  private _applyExternalState(next: PageState) {
487
- const { sections, warnings } = normaliseSections(next?.sections, key => this.registry.get(key));
488
- warnings.forEach(warning => console.warn(`<zn-page-builder> ${warning}`));
439
+ const seen = new Set<string>();
440
+ let clippedChildren = false;
441
+ // Nesting is one level deep by design — grandchildren in malformed input are
442
+ // dropped, which also bounds the recursion. Duplicate ids get regenerated so
443
+ // selection/patching can never target two sections at once, and children are
444
+ // capped (slots are single-digit by design; MAX_SECTIONS alone would still
445
+ // admit one section with a huge children array).
446
+ const normalise = (s: PageSection, depth: number): PageSection => {
447
+ const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
448
+ seen.add(id);
449
+ if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
450
+ return {
451
+ id,
452
+ type: s.type,
453
+ label: s.label,
454
+ data: structuredClone(s.data ?? {}),
455
+ ...(s.children && depth === 0
456
+ ? { children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
457
+ : {}),
458
+ };
459
+ };
460
+ const incoming = (next?.sections ?? []).filter(s => s && typeof s.type === 'string');
461
+ if (incoming.length > MAX_SECTIONS) {
462
+ console.warn(`<zn-page-builder> config has ${incoming.length} sections; keeping the first ${MAX_SECTIONS}`);
463
+ }
464
+ const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
465
+ if (clippedChildren) {
466
+ console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
467
+ }
489
468
  this._history = [];
490
469
  this._redoStack = [];
491
470
  this._state = { sections: this._requireFirst(sections) };
492
471
  this.defaultValue = JSON.stringify(this._state);
493
472
  this._selectedId = null;
494
473
  this._pickerIndex = null;
495
- this._cellPicker = null;
496
474
  this._offerRestoreIfNewer();
497
475
  }
498
476
 
@@ -525,33 +503,69 @@ export default class ZnPageBuilder extends ZincElement {
525
503
  private _requireFirst(sections: PageSection[]): PageSection[] {
526
504
  if (!this.requiredFirst || sections[0]?.type === this.requiredFirst) return sections;
527
505
  const at = sections.findIndex(s => s.type === this.requiredFirst);
528
- if (at === -1) return [this._newSection(this.requiredFirst), ...sections];
506
+ if (at === -1) return [{ id: generateSectionId(), type: this.requiredFirst, data: {} }, ...sections];
529
507
  const hoisted = [...sections];
530
508
  hoisted.unshift(...hoisted.splice(at, 1));
531
509
  return hoisted;
532
510
  }
533
511
 
534
- /**
535
- * Installs a new state from a user edit and notifies listeners. Re-applies
536
- * growth normalisation across the tree first, so a growable container never
537
- * carries a trailing empty row past this point — the read path (`containerCells`)
538
- * already hides it, but state/value/zn-page-change/auto-save must not diverge
539
- * from what the canvas renders.
540
- */
512
+ /** Finds a section by id, searching top-level sections and slotted children. */
513
+ private _findSection(id: string | null): PageSection | undefined {
514
+ if (!id) return undefined;
515
+ for (const s of this._state.sections) {
516
+ if (s.id === id) return s;
517
+ const child = s.children?.find(c => c?.id === id);
518
+ if (child) return child;
519
+ }
520
+ return undefined;
521
+ }
522
+
523
+ /** New sections array with the section patched wherever it lives (top level or slot). */
524
+ private _patchSection(id: string, patch: (s: PageSection) => PageSection): PageSection[] {
525
+ return this._state.sections.map(s => {
526
+ if (s.id === id) return patch(s);
527
+ if (s.children?.some(c => c?.id === id)) {
528
+ return { ...s, children: s.children.map(c => (c?.id === id ? patch(c) : c)) };
529
+ }
530
+ return s;
531
+ });
532
+ }
533
+
534
+ /** Detaches a section wherever it lives: removed from the top level, or its slot nulled. */
535
+ private _extract(id: string): [PageSection | undefined, PageSection[]] {
536
+ let removed: PageSection | undefined;
537
+ const sections: PageSection[] = [];
538
+ for (const s of this._state.sections) {
539
+ if (s.id === id) {
540
+ removed = s;
541
+ continue;
542
+ }
543
+ const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
544
+ if (slot !== -1) {
545
+ removed = s.children![slot] ?? undefined;
546
+ sections.push({ ...s, children: s.children!.map((c, i) => (i === slot ? null : c)) });
547
+ } else {
548
+ sections.push(s);
549
+ }
550
+ }
551
+ return [removed, sections];
552
+ }
553
+
554
+ /** Installs a new state from a user edit and notifies listeners. */
541
555
  private _commit(next: PageState) {
542
- this._state = { sections: normaliseGrowth(next.sections, key => this.registry.get(key)) };
556
+ this._state = next;
543
557
  this.emit('zn-page-change', { detail: { state: this.state } });
544
558
  }
545
559
 
546
560
  private _selectedSection(): PageSection | undefined {
547
- return findSection(this._state.sections, this._selectedId);
561
+ return this._findSection(this._selectedId);
548
562
  }
549
563
 
550
564
  private _select(id: string | null) {
551
565
  if (this._selectedId === id) return;
552
566
  this._selectedId = id;
553
567
  this._pickerIndex = null;
554
- this._cellPicker = null;
568
+ this._slotPicker = null;
555
569
  this.emit('zn-page-selection-change', { detail: { sectionId: id } });
556
570
  }
557
571
 
@@ -583,22 +597,11 @@ export default class ZnPageBuilder extends ZincElement {
583
597
 
584
598
  // --- Section mutations ------------------------------------------------------
585
599
 
586
- /** A fresh section of a registered type, seeded with layout/cells when it's a container. */
587
- private _newSection(type: string): PageSection {
588
- const sectionType = this.registry.get(type);
589
- const section: PageSection = { id: generateSectionId(), type, data: {} };
590
- if (isContainer(sectionType)) {
591
- section.layout = defaultLayout(sectionType);
592
- section.cells = containerCells(section, sectionType);
593
- }
594
- return section;
595
- }
596
-
597
600
  /** Adds a section of a registered type at `index` (default: end). Returns null for unknown types. */
598
601
  addSection(type: string, index?: number): PageSection | null {
599
602
  if (!this.registry.has(type)) return null;
600
603
  this._pushHistory();
601
- const section = this._newSection(type);
604
+ const section: PageSection = { id: generateSectionId(), type, data: {} };
602
605
  const sections = [...this._state.sections];
603
606
  sections.splice(Math.max(index ?? sections.length, this._firstFreeIndex), 0, section);
604
607
  this._commit({ sections });
@@ -606,15 +609,33 @@ export default class ZnPageBuilder extends ZincElement {
606
609
  return section;
607
610
  }
608
611
 
612
+ /** Adds a new section of a registered type into a container's slot. Returns null if not allowed. */
613
+ addSectionToSlot(type: string, containerId: string, slotIndex: number): PageSection | null {
614
+ const sectionType = this.registry.get(type);
615
+ const container = this._findSection(containerId);
616
+ const containerType = container ? this.registry.get(container.type) : undefined;
617
+ if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
618
+ if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
619
+ if (containerType.accepts && !containerType.accepts.includes(type)) return null;
620
+ const children = sectionChildren(container, containerType);
621
+ if (children[slotIndex]) return null;
622
+ const section: PageSection = { id: generateSectionId(), type, data: {} };
623
+ children[slotIndex] = section;
624
+ this._pushHistory();
625
+ this._commit({ sections: this._patchSection(containerId, s => ({ ...s, children })) });
626
+ this._select(section.id);
627
+ return section;
628
+ }
629
+
609
630
  private _removeSection(id: string) {
610
631
  if (this._isPinned(id)) return;
611
- const [removed, sections] = extractSection(this._state.sections, id);
632
+ const [removed, sections] = this._extract(id);
612
633
  if (!removed) return;
613
634
  this._pushHistory();
614
- // Clear selection for the removed section AND anything inside it, at any depth.
615
- const stillSelected = (s: PageSection): boolean =>
616
- s.id === this._selectedId || (s.cells ?? []).some(cell => cell.some(stillSelected));
617
- if (stillSelected(removed)) this._select(null);
635
+ // Clear selection for the removed section AND anything inside it.
636
+ if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
637
+ this._select(null);
638
+ }
618
639
  this._commit({ sections });
619
640
  }
620
641
 
@@ -622,51 +643,35 @@ export default class ZnPageBuilder extends ZincElement {
622
643
  const index = this._state.sections.findIndex(s => s.id === id);
623
644
  if (index !== -1) {
624
645
  this._pushHistory();
625
- const copy = cloneWithNewIds(this._state.sections[index]);
646
+ const copy = structuredClone(this._state.sections[index]);
647
+ copy.id = generateSectionId();
648
+ copy.children = copy.children?.map(c => (c ? { ...c, id: generateSectionId() } : null));
626
649
  const sections = [...this._state.sections];
627
650
  sections.splice(index + 1, 0, copy);
628
651
  this._commit({ sections });
629
652
  this._select(copy.id);
630
653
  return;
631
654
  }
632
- // A section inside a cell duplicates directly below itself in that stack.
633
- const owner = this._findCellOwner(id);
634
- if (!owner) return;
635
- const copy = cloneWithNewIds(findSection(this._state.sections, id)!);
636
- this._pushHistory();
637
- this._commit({
638
- sections: insertIntoCell(
639
- this._state.sections, owner.containerId, owner.cellIndex, owner.index + 1, copy, owner.columns
640
- ),
641
- });
642
- this._select(copy.id);
643
- }
644
-
645
- /** Locates a section living inside a container cell. */
646
- private _findCellOwner(id: string): CellOwner | undefined {
647
- const search = (sections: PageSection[]): CellOwner | undefined => {
648
- for (const section of sections) {
649
- const cells = section.cells ?? [];
650
- for (let cellIndex = 0; cellIndex < cells.length; cellIndex++) {
651
- const index = cells[cellIndex].findIndex(child => child.id === id);
652
- if (index !== -1) {
653
- return {
654
- containerId: section.id,
655
- cellIndex,
656
- index,
657
- columns: containerColumns(section, this.registry.get(section.type)),
658
- };
659
- }
660
- const deeper = search(cells[cellIndex]);
661
- if (deeper) return deeper;
662
- }
663
- }
664
- return undefined;
665
- };
666
- return search(this._state.sections);
655
+ // A slotted child duplicates into its container's next empty slot, if any.
656
+ for (const s of this._state.sections) {
657
+ const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
658
+ if (slot === -1) continue;
659
+ const type = this.registry.get(s.type);
660
+ if (!type?.slots) return;
661
+ const children = sectionChildren(s, type);
662
+ const empty = children.findIndex(c => !c);
663
+ if (empty === -1) return;
664
+ const copy = structuredClone(children[slot]!);
665
+ copy.id = generateSectionId();
666
+ children[empty] = copy;
667
+ this._pushHistory();
668
+ this._commit({ sections: this._patchSection(s.id, x => ({ ...x, children })) });
669
+ this._select(copy.id);
670
+ return;
671
+ }
667
672
  }
668
673
 
669
- /** Moves a section (top-level or inside a cell) to a top-level position. */
674
+ /** Moves a section (top-level or slotted) to a top-level position. */
670
675
  private _moveSection(id: string, index: number) {
671
676
  if (this._isPinned(id)) return;
672
677
  index = Math.max(index, this._firstFreeIndex);
@@ -681,22 +686,60 @@ export default class ZnPageBuilder extends ZincElement {
681
686
  this._commit({ sections });
682
687
  return;
683
688
  }
684
- const [moved, sections] = extractSection(this._state.sections, id);
689
+ const [moved, sections] = this._extract(id);
685
690
  if (!moved) return;
686
691
  this._pushHistory();
687
692
  sections.splice(index, 0, moved);
688
693
  this._commit({ sections });
689
694
  }
690
695
 
691
- // --- Canvas drag & drop -----------------------------------------------------
696
+ /**
697
+ * Moves a section into a container's slot. Dropping onto an occupied slot swaps
698
+ * the two children (slot-to-slot reordering); top-level sections and containers
699
+ * only enter empty slots / never enter slots respectively.
700
+ */
701
+ private _moveToSlot(id: string, containerId: string, slotIndex: number) {
702
+ const moved = this._findSection(id);
703
+ const container = this._findSection(containerId);
704
+ const containerType = container ? this.registry.get(container.type) : undefined;
705
+ if (!moved || !container || !containerType?.slots || id === containerId) return;
706
+ if (this._isPinned(id)) return; // the pinned section stays at the top of the page
707
+ if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
708
+ if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
709
+ if (slotIndex < 0 || slotIndex >= containerType.slots) return;
710
+
711
+ const target = container.children?.[slotIndex] ?? null;
712
+ if (target?.id === id) return;
713
+ const fromTop = this._state.sections.some(s => s.id === id);
714
+ if (target && fromTop) return; // top-level sections only drop on empty slots
715
+
716
+ this._pushHistory();
717
+ const sections = structuredClone(this._state.sections);
718
+ const containerRef = sections.find(s => s.id === containerId)!;
719
+ containerRef.children = Array.from({ length: containerType.slots }, (_, i) => containerRef.children?.[i] ?? null);
720
+ const movedCopy = structuredClone(moved);
721
+
722
+ if (fromTop) {
723
+ sections.splice(sections.findIndex(s => s.id === id), 1);
724
+ } else {
725
+ // Swap: the occupant (or null) takes the source slot.
726
+ for (const s of sections) {
727
+ const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
728
+ if (slot !== -1) {
729
+ s.children![slot] = target ? structuredClone(target) : null;
730
+ break;
731
+ }
732
+ }
733
+ }
734
+ containerRef.children[slotIndex] = movedCopy;
735
+ this._commit({ sections });
736
+ }
692
737
 
693
- /** Id of the section currently being dragged — dataTransfer is unreadable during dragover. */
694
- private _draggingId: string | null = null;
738
+ // --- Canvas drag & drop -----------------------------------------------------
695
739
 
696
740
  private _onCardDragStart(e: DragEvent, id: string) {
697
741
  if (!e.dataTransfer) return;
698
742
  e.stopPropagation(); // a child card's drag must not also start its container's
699
- this._draggingId = id;
700
743
  e.dataTransfer.setData(PAGE_SECTION_MIME, id);
701
744
  e.dataTransfer.effectAllowed = 'move';
702
745
  }
@@ -707,6 +750,24 @@ export default class ZnPageBuilder extends ZincElement {
707
750
  return types.includes(PAGE_TYPE_MIME) || types.includes(PAGE_SECTION_MIME);
708
751
  }
709
752
 
753
+ private _onSlotDragOver(e: DragEvent, containerId: string, index: number) {
754
+ if (!this._isPageDrag(e)) return;
755
+ e.preventDefault();
756
+ e.stopPropagation();
757
+ this._dragOverIndex = null;
758
+ this._slotDragOver = { containerId, index };
759
+ }
760
+
761
+ private _onSlotDrop(e: DragEvent, containerId: string, index: number) {
762
+ e.preventDefault();
763
+ e.stopPropagation();
764
+ this._slotDragOver = null;
765
+ const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
766
+ const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
767
+ if (typeKey) this.addSectionToSlot(typeKey, containerId, index);
768
+ else if (sectionId) this._moveToSlot(sectionId, containerId, index);
769
+ }
770
+
710
771
  private _onZoneDragOver(e: DragEvent, index: number) {
711
772
  if (!this._isPageDrag(e)) return;
712
773
  e.preventDefault();
@@ -736,124 +797,6 @@ export default class ZnPageBuilder extends ZincElement {
736
797
  this._onZoneDrop(e, this._state.sections.length);
737
798
  };
738
799
 
739
- // --- Container cell drag & drop ----------------------------------------------
740
-
741
- /**
742
- * Whether `typeKey`'s registered type is allowed into this container by its
743
- * `accepts`/`slots=` rule — the nesting depth cap is a separate concern,
744
- * checked by each caller against its own notion of how tall the dropped
745
- * subtree is.
746
- */
747
- private _acceptsType(container: PageSection, typeKey: string): boolean {
748
- const containerType = this.registry.get(container.type);
749
- const dragged = this.registry.get(typeKey);
750
- if (!dragged || !isContainer(containerType)) return false;
751
- if (containerType!.accepts) return containerType!.accepts.includes(typeKey);
752
- // The deprecated `slots=` alias keeps its stricter non-containers-only rule.
753
- if (!containerType!.container) return !isContainer(dragged);
754
- return true;
755
- }
756
-
757
- /**
758
- * Whether `typeKey` may be dropped into this container's cells from the
759
- * palette. A newly created section always has height 1, so the depth cap
760
- * only needs the target's own depth.
761
- */
762
- private _acceptsInCell(container: PageSection, typeKey: string): boolean {
763
- const dragged = this.registry.get(typeKey);
764
- // The nesting depth cap is enforced independently of `accepts` — an explicit
765
- // allow-list must not be able to punch through MAX_CONTAINER_LEVELS.
766
- if (dragged && isContainer(dragged) && containerDepth(this._state.sections, container.id) >= MAX_CONTAINER_LEVELS) {
767
- return false;
768
- }
769
- return this._acceptsType(container, typeKey);
770
- }
771
-
772
- /**
773
- * Whether a dragged, already-placed section may land in this container's
774
- * cells. Unlike a palette drop, the moved subtree can already be several
775
- * containers tall (it may itself hold a nested container), so the cap has to
776
- * account for that height, not just the target's depth: dropping `moved` at
777
- * a new depth of `containerDepth(container) + 1` puts its own deepest
778
- * descendant at `containerDepth(container) + containerHeight(moved)`.
779
- */
780
- private _canMoveIntoCell(container: PageSection, sectionId: string): boolean {
781
- if (this._isPinned(sectionId)) return false;
782
- if (sectionId === container.id) return false;
783
- const moved = findSection(this._state.sections, sectionId);
784
- if (!moved) return false;
785
- // Refuse dropping a container into its own subtree.
786
- if (findSection([moved], container.id)) return false;
787
- if (containerDepth(this._state.sections, container.id) + containerHeight(moved) > MAX_CONTAINER_LEVELS) {
788
- return false;
789
- }
790
- return this._acceptsType(container, moved.type);
791
- }
792
-
793
- private _onCellDragOver(e: DragEvent, containerId: string, cellIndex: number, insertIndex: number) {
794
- if (!this._isPageDrag(e)) return;
795
- const container = findSection(this._state.sections, containerId);
796
- if (!container) return;
797
- // Claim the event before deciding accept/refuse — a nested cell is the deepest
798
- // target and must not let a refusal bubble up into an ancestor cell that accepts.
799
- e.stopPropagation();
800
- const types = e.dataTransfer ? Array.from(e.dataTransfer.types) : [];
801
- // A section drag is validated here from its own tracked id, since dataTransfer
802
- // values are unreadable during a real dragover (protected mode).
803
- if (types.includes(PAGE_SECTION_MIME) && this._draggingId && !this._canMoveIntoCell(container, this._draggingId)) {
804
- return;
805
- }
806
- // A type-key drag's payload is unreadable during a real dragover too, so this
807
- // only enforces `accepts`/depth when the payload happens to be readable; the
808
- // definitive check is on drop.
809
- const typeKey = types.includes(PAGE_TYPE_MIME) ? e.dataTransfer?.getData(PAGE_TYPE_MIME) : '';
810
- if (typeKey && !this._acceptsInCell(container, typeKey)) return;
811
- e.preventDefault();
812
- this._dragOverIndex = null;
813
- this._cellDragOver = { containerId, cellIndex, insertIndex };
814
- }
815
-
816
- private _onCellDrop(e: DragEvent, containerId: string, cellIndex: number, insertIndex: number) {
817
- e.preventDefault();
818
- e.stopPropagation();
819
- this._cellDragOver = null;
820
- const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
821
- const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
822
- if (typeKey) this.addSectionToCell(typeKey, containerId, cellIndex, insertIndex);
823
- else if (sectionId) this._moveToCell(sectionId, containerId, cellIndex, insertIndex);
824
- }
825
-
826
- /** Adds a new section of a registered type into a container cell. */
827
- addSectionToCell(type: string, containerId: string, cellIndex: number, insertIndex = 0): PageSection | null {
828
- const container = findSection(this._state.sections, containerId);
829
- if (!container || !this._acceptsInCell(container, type)) return null;
830
- const section = this._newSection(type);
831
- this._pushHistory();
832
- this._commit({
833
- sections: insertIntoCell(
834
- this._state.sections, containerId, cellIndex, insertIndex, section,
835
- containerColumns(container, this.registry.get(container.type))
836
- ),
837
- });
838
- this._select(section.id);
839
- return section;
840
- }
841
-
842
- private _moveToCell(id: string, containerId: string, cellIndex: number, insertIndex: number) {
843
- const container = findSection(this._state.sections, containerId);
844
- if (!container || !this._canMoveIntoCell(container, id)) return;
845
- const columns = containerColumns(container, this.registry.get(container.type));
846
- const [moved, without] = extractSection(this._state.sections, id);
847
- if (!moved) return;
848
- this._pushHistory();
849
- this._commit({ sections: insertIntoCell(without, containerId, cellIndex, insertIndex, moved, columns) });
850
- }
851
-
852
- /** Types offerable in a container's cells. */
853
- private _cellTypes(container: PageSection): PageSectionType[] {
854
- return this.registry.all().filter(t => this._acceptsInCell(container, t.type));
855
- }
856
-
857
800
  private _onCardKeydown(e: KeyboardEvent, id: string) {
858
801
  if (e.key === 'Delete' || e.key === 'Backspace') {
859
802
  e.preventDefault();
@@ -1021,92 +964,59 @@ export default class ZnPageBuilder extends ZincElement {
1021
964
  }
1022
965
 
1023
966
  private _renderCard(section: PageSection, index: number) {
1024
- return this._renderNode(section, {
967
+ const type = this.registry.get(section.type);
968
+ const card = this._renderSectionCard(section, {
1025
969
  over: e => this._onZoneDragOver(e, index + 1),
1026
970
  drop: e => this._onZoneDrop(e, index + 1),
1027
971
  });
1028
- }
1029
-
1030
- /**
1031
- * A card, or — for a container — its card plus its own cell grid. Shared by the
1032
- * top-level section list and container-cell stacks, so nesting renders at every level.
1033
- */
1034
- private _renderNode(
1035
- section: PageSection,
1036
- drop: { over: (e: DragEvent) => void; drop: (e: DragEvent) => void },
1037
- extraClass = ''
1038
- ): TemplateResult {
1039
- const type = this.registry.get(section.type);
1040
- const card = this._renderSectionCard(section, drop, extraClass);
1041
- if (!isContainer(type)) return card;
972
+ if (!type?.slots) return card;
1042
973
  return html`
1043
974
  <div class="container">
1044
975
  ${card}
1045
- ${this._renderCells(section, type)}
1046
- </div>`;
1047
- }
1048
-
1049
- /** A container's cell grid: one track per width, one stack per cell. */
1050
- private _renderCells(container: PageSection, type: PageSectionType | undefined): TemplateResult {
1051
- const widths = containerWidths(container, type);
1052
- const cells = containerCells(container, type);
1053
- const grow = containerGrow(container, type);
1054
- // A growable container never persists a trailing empty row, so the extra
1055
- // row of drop targets is added here at render time.
1056
- const rendered = grow ? [...cells, ...Array.from({ length: widths.length }, () => [] as PageSection[])] : cells;
1057
- return html`
1058
- <div class="cells" style="grid-template-columns:${widths.map(w => `${w}fr`).join(' ')}">
1059
- ${rendered.map((stack, cellIndex) => this._renderCell(container, stack, cellIndex))}
976
+ <div class="slots">
977
+ ${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
978
+ </div>
1060
979
  </div>`;
1061
980
  }
1062
981
 
1063
- private _renderCell(container: PageSection, stack: PageSection[], cellIndex: number): TemplateResult {
1064
- const active = this._cellDragOver?.containerId === container.id
1065
- && this._cellDragOver.cellIndex === cellIndex;
1066
- const pickerOpen = this._cellPicker?.containerId === container.id
1067
- && this._cellPicker.cellIndex === cellIndex;
982
+ private _renderSlot(container: PageSection, child: PageSection | null, index: number) {
983
+ if (child) {
984
+ return this._renderSectionCard(child, {
985
+ over: e => this._onSlotDragOver(e, container.id, index),
986
+ drop: e => this._onSlotDrop(e, container.id, index),
987
+ }, 'slot__card');
988
+ }
989
+ const active = this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index;
990
+ const pickerOpen = this._slotPicker?.containerId === container.id && this._slotPicker.index === index;
1068
991
  return html`
1069
992
  <div
1070
- class="cell ${active ? 'cell--active' : ''} ${stack.length ? '' : 'cell--empty'}"
1071
- @dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, stack.length)}"
993
+ class="slot slot--empty ${active ? 'slot--active' : ''}"
994
+ @dragover="${(e: DragEvent) => this._onSlotDragOver(e, container.id, index)}"
1072
995
  @dragleave="${() => {
1073
- if (this._cellDragOver?.containerId === container.id && this._cellDragOver.cellIndex === cellIndex) {
1074
- this._cellDragOver = null;
996
+ if (this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index) {
997
+ this._slotDragOver = null;
1075
998
  }
1076
999
  }}"
1077
- @drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, stack.length)}"
1000
+ @drop="${(e: DragEvent) => this._onSlotDrop(e, container.id, index)}"
1078
1001
  @click="${(e: Event) => {
1079
1002
  e.stopPropagation();
1080
- this._cellPicker = pickerOpen ? null : { containerId: container.id, cellIndex };
1003
+ this._slotPicker = pickerOpen ? null : { containerId: container.id, index };
1081
1004
  }}">
1082
- ${stack.map((child, i) => html`
1083
- ${this._renderCellStrip(container, cellIndex, i)}
1084
- ${this._renderNode(child, {
1085
- over: e => this._onCellDragOver(e, container.id, cellIndex, i),
1086
- drop: e => this._onCellDrop(e, container.id, cellIndex, i),
1087
- }, 'cell__card')}
1088
- `)}
1089
- ${this._renderCellStrip(container, cellIndex, stack.length)}
1090
- ${stack.length === 0 ? html`<zn-icon src="add" size="16"></zn-icon>` : ''}
1005
+ <zn-icon src="add" size="16"></zn-icon>
1091
1006
  ${pickerOpen
1092
- ? this._renderTypePicker(this._cellTypes(container), t => this.addSectionToCell(t, container.id, cellIndex))
1007
+ ? this._renderTypePicker(this._slotTypes(container), t => this.addSectionToSlot(t, container.id, index))
1093
1008
  : ''}
1094
1009
  </div>`;
1095
1010
  }
1096
1011
 
1097
- /** Thin insertion target between two cards in a stack. */
1098
- private _renderCellStrip(container: PageSection, cellIndex: number, insertIndex: number) {
1099
- const active = this._cellDragOver?.containerId === container.id
1100
- && this._cellDragOver.cellIndex === cellIndex
1101
- && this._cellDragOver.insertIndex === insertIndex;
1102
- return html`
1103
- <div
1104
- class="cell__strip ${active ? 'cell__strip--active' : ''}"
1105
- @dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, insertIndex)}"
1106
- @drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, insertIndex)}"></div>`;
1012
+ /** Types allowed in a container's slots: non-containers, filtered by its accepts list. */
1013
+ private _slotTypes(container: PageSection): PageSectionType[] {
1014
+ const containerType = this.registry.get(container.type);
1015
+ return this.registry.all().filter(t =>
1016
+ !t.slots && (!containerType?.accepts || containerType.accepts.includes(t.type)));
1107
1017
  }
1108
1018
 
1109
- /** The one type-picker template both drop zones and container cells render. */
1019
+ /** The one type-picker template both drop zones and slot cells render. */
1110
1020
  private _renderTypePicker(types: PageSectionType[], pick: (type: string) => void) {
1111
1021
  return html`
1112
1022
  <div class="picker" @click="${(e: Event) => e.stopPropagation()}">
@@ -1116,6 +1026,7 @@ export default class ZnPageBuilder extends ZincElement {
1116
1026
  class="picker__item"
1117
1027
  @click="${() => {
1118
1028
  this._pickerIndex = null;
1029
+ this._slotPicker = null;
1119
1030
  pick(type.type);
1120
1031
  }}">
1121
1032
  <zn-icon src="${type.icon ?? 'widgets'}" library="${ifDefined(type.iconLibrary)}" size="14"></zn-icon>
@@ -1223,121 +1134,12 @@ export default class ZnPageBuilder extends ZincElement {
1223
1134
 
1224
1135
  private _updateSectionData(id: string, patch: Record<string, unknown>) {
1225
1136
  this._pushHistory();
1226
- this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1137
+ this._commit({ sections: this._patchSection(id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1227
1138
  }
1228
1139
 
1229
1140
  private _renameSection(id: string, label: string) {
1230
1141
  this._pushHistory();
1231
- this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, label: label || undefined })) });
1232
- }
1233
-
1234
- /** Replaces a container's layout, keeping cells consistent with it. */
1235
- private _setLayout(
1236
- id: string,
1237
- next: (current: { widths: number[]; grow: boolean; cells: PageSection[][] }) => {
1238
- widths: number[];
1239
- grow: boolean;
1240
- cells: PageSection[][];
1241
- }
1242
- ) {
1243
- const container = findSection(this._state.sections, id);
1244
- if (!container) return;
1245
- const type = this.registry.get(container.type);
1246
- const current = {
1247
- widths: containerWidths(container, type),
1248
- grow: containerGrow(container, type),
1249
- cells: containerCells(container, type),
1250
- };
1251
- const { widths, grow, cells } = next(current);
1252
- this._pushHistory();
1253
- this._commit({
1254
- sections: patchSection(this._state.sections, id, section => ({
1255
- ...section,
1256
- layout: { widths, grow },
1257
- cells: normaliseCells(cells, widths.length, grow),
1258
- })),
1259
- });
1260
- }
1261
-
1262
- private _setColumns(id: string, columns: number) {
1263
- this._setLayout(id, current => {
1264
- const count = Math.min(Math.max(Math.floor(columns) || 1, 1), MAX_COLUMNS);
1265
- const widths = Array.from({ length: count }, (_, i) => current.widths[i] ?? 1);
1266
- return { widths, grow: current.grow, cells: recolumnCells(current.cells, count) };
1267
- });
1268
- }
1269
-
1270
- private _setWidth(id: string, column: number, width: number) {
1271
- const container = findSection(this._state.sections, id);
1272
- if (!container) return;
1273
- const columns = containerWidths(container, this.registry.get(container.type)).length;
1274
- if (column < 0 || column >= columns) return;
1275
- this._setLayout(id, current => {
1276
- const widths = [...current.widths];
1277
- widths[column] = Math.min(Math.max(Math.floor(width) || 1, 1), MAX_WIDTH);
1278
- return { ...current, widths };
1279
- });
1280
- }
1281
-
1282
- private _setGrow(id: string, grow: boolean) {
1283
- this._setLayout(id, current => ({ ...current, grow }));
1284
- }
1285
-
1286
- /** Pads with empty rows, or trims trailing empty rows down to the last occupied one. */
1287
- private _setRows(id: string, rows: number) {
1288
- this._setLayout(id, current => {
1289
- const columns = current.widths.length;
1290
- const wanted = Math.max(Math.floor(rows) || 1, 1);
1291
- const target = wanted * columns;
1292
- const cells = trimTrailingEmptyCells(current.cells);
1293
- while (cells.length < target) cells.push([]);
1294
- return { ...current, cells };
1295
- });
1296
- }
1297
-
1298
- private _renderLayoutGroup(section: PageSection, type: PageSectionType | undefined) {
1299
- if (!isContainer(type)) return '';
1300
- const widths = containerWidths(section, type);
1301
- const grow = containerGrow(section, type);
1302
- const rows = containerCells(section, type).length / widths.length;
1303
- return html`
1304
- <div class="layout-group" @click="${(e: Event) => e.stopPropagation()}">
1305
- <div class="layout-group__title">Layout</div>
1306
- <zn-input
1307
- class="layout-group__columns"
1308
- type="number"
1309
- label="Columns"
1310
- min="1"
1311
- max="${MAX_COLUMNS}"
1312
- .value="${String(widths.length)}"
1313
- @zn-change="${(e: Event) => this._setColumns(section.id, Number((e.target as ZnInput).value))}"></zn-input>
1314
- <div class="layout-group__widths">
1315
- <span class="layout-group__label">Widths</span>
1316
- <div class="layout-group__weights">
1317
- ${widths.map((width, column) => html`
1318
- <zn-input
1319
- type="number"
1320
- min="1"
1321
- max="${MAX_WIDTH}"
1322
- aria-label="Column ${column + 1} width"
1323
- .value="${String(width)}"
1324
- @zn-change="${(e: Event) => this._setWidth(section.id, column, Number((e.target as ZnInput).value))}"></zn-input>`)}
1325
- </div>
1326
- </div>
1327
- <zn-toggle
1328
- label="Keep adding rows"
1329
- label-position="left"
1330
- description="Offers a new row as the last one fills."
1331
- ?checked="${grow}"
1332
- @zn-input="${(e: Event) => this._setGrow(section.id, Boolean((e.target as HTMLInputElement).checked))}"></zn-toggle>
1333
- ${grow ? '' : html`
1334
- <zn-input
1335
- type="number"
1336
- label="Rows"
1337
- min="1"
1338
- .value="${String(rows)}"
1339
- @zn-change="${(e: Event) => this._setRows(section.id, Number((e.target as ZnInput).value))}"></zn-input>`}
1340
- </div>`;
1142
+ this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
1341
1143
  }
1342
1144
 
1343
1145
  private _renderInspector() {
@@ -1376,7 +1178,6 @@ export default class ZnPageBuilder extends ZincElement {
1376
1178
  @zn-change="${this._onInspectorInput}"
1377
1179
  @input="${this._onInspectorInput}"
1378
1180
  @zn-input="${this._onInspectorInput}">
1379
- ${this._renderLayoutGroup(section, type)}
1380
1181
  <zn-input
1381
1182
  class="inspector__rename"
1382
1183
  label="Section name"
@@ -1399,8 +1200,7 @@ export default class ZnPageBuilder extends ZincElement {
1399
1200
  class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
1400
1201
  @dragend="${() => {
1401
1202
  this._dragOverIndex = null;
1402
- this._cellDragOver = null;
1403
- this._draggingId = null;
1203
+ this._slotDragOver = null;
1404
1204
  }}">
1405
1205
  <header part="header" class="header" ?hidden="${!hasHeader}">
1406
1206
  <div class="header__group">