@kubex/zinc 1.1.94 → 1.1.95

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 (36) hide show
  1. package/dist/custom-elements.json +995 -196
  2. package/dist/vscode.html-custom-data.json +74 -19
  3. package/dist/web-types.json +147 -37
  4. package/dist/zn.d.ts +335 -59
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +467 -355
  7. package/docs/pages/components/page-builder.md +121 -19
  8. package/docs/pages/components/slash-menu.md +132 -6
  9. package/docs/pages/components/textarea.md +16 -0
  10. package/package.json +1 -1
  11. package/scss/_root.scss +7 -1
  12. package/src/components/button/button.scss +5 -2
  13. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  14. package/src/components/input/input.component.ts +12 -2
  15. package/src/components/page/page.scss +7 -2
  16. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  17. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  18. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  19. package/src/components/page-builder/page-builder.component.ts +483 -225
  20. package/src/components/page-builder/page-builder.scss +64 -10
  21. package/src/components/page-builder/page-builder.test.ts +656 -110
  22. package/src/components/page-builder/page-tree.test.ts +483 -0
  23. package/src/components/page-builder/page-tree.ts +329 -0
  24. package/src/components/page-builder/page.types.ts +75 -7
  25. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  26. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  27. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  28. package/src/components/settings-container/settings-container.scss +2 -1
  29. package/src/components/slash-item/slash-item.component.ts +1 -1
  30. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  31. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  32. package/src/components/slash-menu/slash-menu.scss +90 -12
  33. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  34. package/src/components/textarea/textarea.component.ts +12 -2
  35. package/src/components/textarea/textarea.test.ts +2 -2
  36. package/src/components/translations/translations.component.ts +5 -1
@@ -1,13 +1,36 @@
1
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
2
1
  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,
3
23
  emptyPageState,
4
24
  generateSectionId,
25
+ isContainer,
26
+ MAX_COLUMNS,
27
+ MAX_CONTAINER_LEVELS,
28
+ MAX_WIDTH,
5
29
  PAGE_SECTION_MIME,
6
30
  PAGE_TYPE_MIME,
7
31
  type PageSection,
8
32
  type PageSectionType,
9
33
  type PageState,
10
- sectionChildren,
11
34
  sectionSummary,
12
35
  } from './page.types';
13
36
  import { FormControlController, validValidityState } from '../../internal/form';
@@ -22,14 +45,11 @@ import ZnIcon from '../icon';
22
45
  import ZnInput from '../input';
23
46
  import ZnPagePaletteItem from './modules/page-palette-item';
24
47
  import ZnPageSectionCard from './modules/page-section-card';
48
+ import ZnToggle from '../toggle';
25
49
 
26
50
  import styles from './page-builder.scss';
27
51
 
28
52
  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;
33
53
  /** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
34
54
  const NARROW_WIDTH = 768;
35
55
 
@@ -48,6 +68,14 @@ function timeAgo(ms: number): string {
48
68
  return `${Math.floor(h / 24)}d ago`;
49
69
  }
50
70
 
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
+
51
79
  /**
52
80
  * @summary A config-driven page composer: a palette of predefined section types, a linear
53
81
  * canvas of section cards, and an inspector for editing each section's content.
@@ -65,10 +93,14 @@ function timeAgo(ms: number): string {
65
93
  * @event zn-page-selection-change - Emitted when the selected section changes. `event.detail.sectionId`.
66
94
  *
67
95
  * @slot config - `<template type="…">` declarations; never displayed. Each template's attributes
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.
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.
72
104
  * @slot header-left - Actions shown on the left of the header bar.
73
105
  * @slot header-right - Actions shown on the right of the header bar.
74
106
  *
@@ -89,6 +121,7 @@ export default class ZnPageBuilder extends ZincElement {
89
121
  'zn-input': ZnInput,
90
122
  'zn-page-palette-item': ZnPagePaletteItem,
91
123
  'zn-page-section-card': ZnPageSectionCard,
124
+ 'zn-toggle': ZnToggle,
92
125
  };
93
126
 
94
127
  private readonly formControlController = new FormControlController(this);
@@ -105,6 +138,14 @@ export default class ZnPageBuilder extends ZincElement {
105
138
  @property({ reflect: true }) heading = '';
106
139
  @property({ reflect: true }) subheading = '';
107
140
 
141
+ /**
142
+ * Section type key that must lead the page. The builder hoists an existing section of
143
+ * that type to the top, or inserts an empty one, and pins it there: it can't be
144
+ * removed, reordered or dragged into a slot, and nothing can be dropped above it.
145
+ * Its content stays fully editable in the inspector.
146
+ */
147
+ @property({ attribute: 'required-first', reflect: true }) requiredFirst = '';
148
+
108
149
  /** Section types to make available, registered into the internal registry. */
109
150
  @property({ attribute: false }) sectionTypes: PageSectionType[] = [];
110
151
 
@@ -139,10 +180,10 @@ export default class ZnPageBuilder extends ZincElement {
139
180
  /** Index of the drop zone whose "+" type picker is open, if any. */
140
181
  @state() private _pickerIndex: number | null = null;
141
182
  @state() private _dragOverIndex: number | null = null;
142
- /** The container slot a drag is currently over, if any. */
143
- @state() private _slotDragOver: { containerId: string; index: number } | null = null;
144
- /** The container slot whose "+" type picker is open, if any. */
145
- @state() private _slotPicker: { containerId: string; index: 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;
146
187
  /** The stamped config form for the selected section; rebuilt on selection change. */
147
188
  @state() private _form: HTMLDivElement | null = null;
148
189
 
@@ -224,6 +265,15 @@ export default class ZnPageBuilder extends ZincElement {
224
265
  }
225
266
  }
226
267
 
268
+ // Idempotent, so it does not matter whether this or handleConfigChange runs first.
269
+ @watch('requiredFirst')
270
+ handleRequiredFirstChange() {
271
+ const sections = this._requireFirst(this._state.sections);
272
+ if (sections === this._state.sections) return;
273
+ this._state = { sections };
274
+ this.defaultValue = JSON.stringify(this._state);
275
+ }
276
+
227
277
  @watch('sectionTypes')
228
278
  handleSectionTypesChange() {
229
279
  this.registry.registerAll(this.sectionTypes ?? []);
@@ -391,6 +441,18 @@ export default class ZnPageBuilder extends ZincElement {
391
441
  const type = el.getAttribute('type');
392
442
  if (!type) return null;
393
443
  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
+ }
394
456
  return {
395
457
  type,
396
458
  label: el.getAttribute('label') ?? type,
@@ -402,6 +464,9 @@ export default class ZnPageBuilder extends ZincElement {
402
464
  configTemplate: el,
403
465
  slots: slots > 0 ? slots : undefined,
404
466
  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,
405
470
  };
406
471
  }
407
472
 
@@ -419,101 +484,74 @@ export default class ZnPageBuilder extends ZincElement {
419
484
 
420
485
  /** Normalises and installs an externally provided state; resets selection. */
421
486
  private _applyExternalState(next: PageState) {
422
- const seen = new Set<string>();
423
- let clippedChildren = false;
424
- // Nesting is one level deep by design — grandchildren in malformed input are
425
- // dropped, which also bounds the recursion. Duplicate ids get regenerated so
426
- // selection/patching can never target two sections at once, and children are
427
- // capped (slots are single-digit by design; MAX_SECTIONS alone would still
428
- // admit one section with a huge children array).
429
- const normalise = (s: PageSection, depth: number): PageSection => {
430
- const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
431
- seen.add(id);
432
- if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
433
- return {
434
- id,
435
- type: s.type,
436
- label: s.label,
437
- data: structuredClone(s.data ?? {}),
438
- ...(s.children && depth === 0
439
- ? { children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
440
- : {}),
441
- };
442
- };
443
- const incoming = (next?.sections ?? []).filter(s => s && typeof s.type === 'string');
444
- if (incoming.length > MAX_SECTIONS) {
445
- console.warn(`<zn-page-builder> config has ${incoming.length} sections; keeping the first ${MAX_SECTIONS}`);
446
- }
447
- const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
448
- if (clippedChildren) {
449
- console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
450
- }
487
+ const { sections, warnings } = normaliseSections(next?.sections, key => this.registry.get(key));
488
+ warnings.forEach(warning => console.warn(`<zn-page-builder> ${warning}`));
451
489
  this._history = [];
452
490
  this._redoStack = [];
453
- this._state = { sections };
491
+ this._state = { sections: this._requireFirst(sections) };
454
492
  this.defaultValue = JSON.stringify(this._state);
455
493
  this._selectedId = null;
456
494
  this._pickerIndex = null;
495
+ this._cellPicker = null;
457
496
  this._offerRestoreIfNewer();
458
497
  }
459
498
 
460
- /** Finds a section by id, searching top-level sections and slotted children. */
461
- private _findSection(id: string | null): PageSection | undefined {
462
- if (!id) return undefined;
463
- for (const s of this._state.sections) {
464
- if (s.id === id) return s;
465
- const child = s.children?.find(c => c?.id === id);
466
- if (child) return child;
467
- }
468
- return undefined;
499
+ // --- The pinned leading section (`required-first`) ---------------------------
500
+
501
+ /**
502
+ * Id of the section pinned to the top of the page, or null when `required-first`
503
+ * is unset. Derived from the state rather than stored on it, so nothing about the
504
+ * lock leaks into the persisted config.
505
+ */
506
+ private get _pinnedId(): string | null {
507
+ const first = this._state.sections[0];
508
+ return this.requiredFirst && first?.type === this.requiredFirst ? first.id : null;
469
509
  }
470
510
 
471
- /** New sections array with the section patched wherever it lives (top level or slot). */
472
- private _patchSection(id: string, patch: (s: PageSection) => PageSection): PageSection[] {
473
- return this._state.sections.map(s => {
474
- if (s.id === id) return patch(s);
475
- if (s.children?.some(c => c?.id === id)) {
476
- return { ...s, children: s.children.map(c => (c?.id === id ? patch(c) : c)) };
477
- }
478
- return s;
479
- });
511
+ private _isPinned(id: string): boolean {
512
+ return this._pinnedId === id;
480
513
  }
481
514
 
482
- /** Detaches a section wherever it lives: removed from the top level, or its slot nulled. */
483
- private _extract(id: string): [PageSection | undefined, PageSection[]] {
484
- let removed: PageSection | undefined;
485
- const sections: PageSection[] = [];
486
- for (const s of this._state.sections) {
487
- if (s.id === id) {
488
- removed = s;
489
- continue;
490
- }
491
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
492
- if (slot !== -1) {
493
- removed = s.children![slot] ?? undefined;
494
- sections.push({ ...s, children: s.children!.map((c, i) => (i === slot ? null : c)) });
495
- } else {
496
- sections.push(s);
497
- }
498
- }
499
- return [removed, sections];
515
+ /** Lowest top-level index a section may be added or moved to. */
516
+ private get _firstFreeIndex(): number {
517
+ return this._pinnedId === null ? 0 : 1;
500
518
  }
501
519
 
502
- /** Installs a new state from a user edit and notifies listeners. */
520
+ /**
521
+ * Sections reordered so `required-first` leads the page: an existing section of that
522
+ * type is hoisted to the front, otherwise an empty one is prepended. Returns the
523
+ * argument unchanged when there is nothing to do, so callers can compare by identity.
524
+ */
525
+ private _requireFirst(sections: PageSection[]): PageSection[] {
526
+ if (!this.requiredFirst || sections[0]?.type === this.requiredFirst) return sections;
527
+ const at = sections.findIndex(s => s.type === this.requiredFirst);
528
+ if (at === -1) return [this._newSection(this.requiredFirst), ...sections];
529
+ const hoisted = [...sections];
530
+ hoisted.unshift(...hoisted.splice(at, 1));
531
+ return hoisted;
532
+ }
533
+
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
+ */
503
541
  private _commit(next: PageState) {
504
- this._state = next;
542
+ this._state = { sections: normaliseGrowth(next.sections, key => this.registry.get(key)) };
505
543
  this.emit('zn-page-change', { detail: { state: this.state } });
506
544
  }
507
545
 
508
546
  private _selectedSection(): PageSection | undefined {
509
- return this._findSection(this._selectedId);
547
+ return findSection(this._state.sections, this._selectedId);
510
548
  }
511
549
 
512
550
  private _select(id: string | null) {
513
551
  if (this._selectedId === id) return;
514
552
  this._selectedId = id;
515
553
  this._pickerIndex = null;
516
- this._slotPicker = null;
554
+ this._cellPicker = null;
517
555
  this.emit('zn-page-selection-change', { detail: { sectionId: id } });
518
556
  }
519
557
 
@@ -545,44 +583,38 @@ export default class ZnPageBuilder extends ZincElement {
545
583
 
546
584
  // --- Section mutations ------------------------------------------------------
547
585
 
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
+
548
597
  /** Adds a section of a registered type at `index` (default: end). Returns null for unknown types. */
549
598
  addSection(type: string, index?: number): PageSection | null {
550
599
  if (!this.registry.has(type)) return null;
551
600
  this._pushHistory();
552
- const section: PageSection = { id: generateSectionId(), type, data: {} };
601
+ const section = this._newSection(type);
553
602
  const sections = [...this._state.sections];
554
- sections.splice(index ?? sections.length, 0, section);
603
+ sections.splice(Math.max(index ?? sections.length, this._firstFreeIndex), 0, section);
555
604
  this._commit({ sections });
556
605
  this._select(section.id);
557
606
  return section;
558
607
  }
559
608
 
560
- /** Adds a new section of a registered type into a container's slot. Returns null if not allowed. */
561
- addSectionToSlot(type: string, containerId: string, slotIndex: number): PageSection | null {
562
- const sectionType = this.registry.get(type);
563
- const container = this._findSection(containerId);
564
- const containerType = container ? this.registry.get(container.type) : undefined;
565
- if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
566
- if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
567
- if (containerType.accepts && !containerType.accepts.includes(type)) return null;
568
- const children = sectionChildren(container, containerType);
569
- if (children[slotIndex]) return null;
570
- const section: PageSection = { id: generateSectionId(), type, data: {} };
571
- children[slotIndex] = section;
572
- this._pushHistory();
573
- this._commit({ sections: this._patchSection(containerId, s => ({ ...s, children })) });
574
- this._select(section.id);
575
- return section;
576
- }
577
-
578
609
  private _removeSection(id: string) {
579
- const [removed, sections] = this._extract(id);
610
+ if (this._isPinned(id)) return;
611
+ const [removed, sections] = extractSection(this._state.sections, id);
580
612
  if (!removed) return;
581
613
  this._pushHistory();
582
- // Clear selection for the removed section AND anything inside it.
583
- if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
584
- this._select(null);
585
- }
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);
586
618
  this._commit({ sections });
587
619
  }
588
620
 
@@ -590,36 +622,54 @@ export default class ZnPageBuilder extends ZincElement {
590
622
  const index = this._state.sections.findIndex(s => s.id === id);
591
623
  if (index !== -1) {
592
624
  this._pushHistory();
593
- const copy = structuredClone(this._state.sections[index]);
594
- copy.id = generateSectionId();
595
- copy.children = copy.children?.map(c => (c ? { ...c, id: generateSectionId() } : null));
625
+ const copy = cloneWithNewIds(this._state.sections[index]);
596
626
  const sections = [...this._state.sections];
597
627
  sections.splice(index + 1, 0, copy);
598
628
  this._commit({ sections });
599
629
  this._select(copy.id);
600
630
  return;
601
631
  }
602
- // A slotted child duplicates into its container's next empty slot, if any.
603
- for (const s of this._state.sections) {
604
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
605
- if (slot === -1) continue;
606
- const type = this.registry.get(s.type);
607
- if (!type?.slots) return;
608
- const children = sectionChildren(s, type);
609
- const empty = children.findIndex(c => !c);
610
- if (empty === -1) return;
611
- const copy = structuredClone(children[slot]!);
612
- copy.id = generateSectionId();
613
- children[empty] = copy;
614
- this._pushHistory();
615
- this._commit({ sections: this._patchSection(s.id, x => ({ ...x, children })) });
616
- this._select(copy.id);
617
- return;
618
- }
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);
619
643
  }
620
644
 
621
- /** Moves a section (top-level or slotted) to a top-level position. */
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);
667
+ }
668
+
669
+ /** Moves a section (top-level or inside a cell) to a top-level position. */
622
670
  private _moveSection(id: string, index: number) {
671
+ if (this._isPinned(id)) return;
672
+ index = Math.max(index, this._firstFreeIndex);
623
673
  const from = this._state.sections.findIndex(s => s.id === id);
624
674
  if (from !== -1) {
625
675
  const to = index > from ? index - 1 : index;
@@ -631,59 +681,22 @@ export default class ZnPageBuilder extends ZincElement {
631
681
  this._commit({ sections });
632
682
  return;
633
683
  }
634
- const [moved, sections] = this._extract(id);
684
+ const [moved, sections] = extractSection(this._state.sections, id);
635
685
  if (!moved) return;
636
686
  this._pushHistory();
637
687
  sections.splice(index, 0, moved);
638
688
  this._commit({ sections });
639
689
  }
640
690
 
641
- /**
642
- * Moves a section into a container's slot. Dropping onto an occupied slot swaps
643
- * the two children (slot-to-slot reordering); top-level sections and containers
644
- * only enter empty slots / never enter slots respectively.
645
- */
646
- private _moveToSlot(id: string, containerId: string, slotIndex: number) {
647
- const moved = this._findSection(id);
648
- const container = this._findSection(containerId);
649
- const containerType = container ? this.registry.get(container.type) : undefined;
650
- if (!moved || !container || !containerType?.slots || id === containerId) return;
651
- if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
652
- if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
653
- if (slotIndex < 0 || slotIndex >= containerType.slots) return;
654
-
655
- const target = container.children?.[slotIndex] ?? null;
656
- if (target?.id === id) return;
657
- const fromTop = this._state.sections.some(s => s.id === id);
658
- if (target && fromTop) return; // top-level sections only drop on empty slots
659
-
660
- this._pushHistory();
661
- const sections = structuredClone(this._state.sections);
662
- const containerRef = sections.find(s => s.id === containerId)!;
663
- containerRef.children = Array.from({ length: containerType.slots }, (_, i) => containerRef.children?.[i] ?? null);
664
- const movedCopy = structuredClone(moved);
665
-
666
- if (fromTop) {
667
- sections.splice(sections.findIndex(s => s.id === id), 1);
668
- } else {
669
- // Swap: the occupant (or null) takes the source slot.
670
- for (const s of sections) {
671
- const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
672
- if (slot !== -1) {
673
- s.children![slot] = target ? structuredClone(target) : null;
674
- break;
675
- }
676
- }
677
- }
678
- containerRef.children[slotIndex] = movedCopy;
679
- this._commit({ sections });
680
- }
681
-
682
691
  // --- Canvas drag & drop -----------------------------------------------------
683
692
 
693
+ /** Id of the section currently being dragged — dataTransfer is unreadable during dragover. */
694
+ private _draggingId: string | null = null;
695
+
684
696
  private _onCardDragStart(e: DragEvent, id: string) {
685
697
  if (!e.dataTransfer) return;
686
698
  e.stopPropagation(); // a child card's drag must not also start its container's
699
+ this._draggingId = id;
687
700
  e.dataTransfer.setData(PAGE_SECTION_MIME, id);
688
701
  e.dataTransfer.effectAllowed = 'move';
689
702
  }
@@ -694,24 +707,6 @@ export default class ZnPageBuilder extends ZincElement {
694
707
  return types.includes(PAGE_TYPE_MIME) || types.includes(PAGE_SECTION_MIME);
695
708
  }
696
709
 
697
- private _onSlotDragOver(e: DragEvent, containerId: string, index: number) {
698
- if (!this._isPageDrag(e)) return;
699
- e.preventDefault();
700
- e.stopPropagation();
701
- this._dragOverIndex = null;
702
- this._slotDragOver = { containerId, index };
703
- }
704
-
705
- private _onSlotDrop(e: DragEvent, containerId: string, index: number) {
706
- e.preventDefault();
707
- e.stopPropagation();
708
- this._slotDragOver = null;
709
- const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
710
- const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
711
- if (typeKey) this.addSectionToSlot(typeKey, containerId, index);
712
- else if (sectionId) this._moveToSlot(sectionId, containerId, index);
713
- }
714
-
715
710
  private _onZoneDragOver(e: DragEvent, index: number) {
716
711
  if (!this._isPageDrag(e)) return;
717
712
  e.preventDefault();
@@ -741,6 +736,124 @@ export default class ZnPageBuilder extends ZincElement {
741
736
  this._onZoneDrop(e, this._state.sections.length);
742
737
  };
743
738
 
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
+
744
857
  private _onCardKeydown(e: KeyboardEvent, id: string) {
745
858
  if (e.key === 'Delete' || e.key === 'Backspace') {
746
859
  e.preventDefault();
@@ -866,7 +979,7 @@ export default class ZnPageBuilder extends ZincElement {
866
979
  Drag sections here to build your page
867
980
  </div>` : ''}
868
981
  ${sections.map((section, i) => html`
869
- ${this._renderDropZone(i)}
982
+ ${i === 0 && this._pinnedId !== null ? '' : this._renderDropZone(i)}
870
983
  ${this._renderCard(section, i)}
871
984
  `)}
872
985
  ${this._renderDropZone(sections.length)}
@@ -881,10 +994,11 @@ export default class ZnPageBuilder extends ZincElement {
881
994
  extraClass = ''
882
995
  ) {
883
996
  const type = this.registry.get(section.type);
997
+ const pinned = this._isPinned(section.id);
884
998
  return html`
885
999
  <zn-page-section-card
886
1000
  class="${extraClass}"
887
- draggable="true"
1001
+ draggable="${pinned ? 'false' : 'true'}"
888
1002
  tabindex="0"
889
1003
  label="${section.label ?? type?.label ?? section.type}"
890
1004
  summary="${type ? sectionSummary(section, type) : `Unknown type "${section.type}"`}"
@@ -893,6 +1007,7 @@ export default class ZnPageBuilder extends ZincElement {
893
1007
  color="${ifDefined(type?.color)}"
894
1008
  ?selected="${this._selectedId === section.id}"
895
1009
  ?unknown="${!type}"
1010
+ ?locked="${pinned}"
896
1011
  @click="${(e: Event) => {
897
1012
  e.stopPropagation();
898
1013
  this._select(section.id);
@@ -906,59 +1021,92 @@ export default class ZnPageBuilder extends ZincElement {
906
1021
  }
907
1022
 
908
1023
  private _renderCard(section: PageSection, index: number) {
909
- const type = this.registry.get(section.type);
910
- const card = this._renderSectionCard(section, {
1024
+ return this._renderNode(section, {
911
1025
  over: e => this._onZoneDragOver(e, index + 1),
912
1026
  drop: e => this._onZoneDrop(e, index + 1),
913
1027
  });
914
- if (!type?.slots) return card;
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;
915
1042
  return html`
916
1043
  <div class="container">
917
1044
  ${card}
918
- <div class="slots">
919
- ${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
920
- </div>
1045
+ ${this._renderCells(section, type)}
921
1046
  </div>`;
922
1047
  }
923
1048
 
924
- private _renderSlot(container: PageSection, child: PageSection | null, index: number) {
925
- if (child) {
926
- return this._renderSectionCard(child, {
927
- over: e => this._onSlotDragOver(e, container.id, index),
928
- drop: e => this._onSlotDrop(e, container.id, index),
929
- }, 'slot__card');
930
- }
931
- const active = this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index;
932
- const pickerOpen = this._slotPicker?.containerId === container.id && this._slotPicker.index === index;
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))}
1060
+ </div>`;
1061
+ }
1062
+
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;
933
1068
  return html`
934
1069
  <div
935
- class="slot slot--empty ${active ? 'slot--active' : ''}"
936
- @dragover="${(e: DragEvent) => this._onSlotDragOver(e, container.id, index)}"
1070
+ class="cell ${active ? 'cell--active' : ''} ${stack.length ? '' : 'cell--empty'}"
1071
+ @dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, stack.length)}"
937
1072
  @dragleave="${() => {
938
- if (this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index) {
939
- this._slotDragOver = null;
1073
+ if (this._cellDragOver?.containerId === container.id && this._cellDragOver.cellIndex === cellIndex) {
1074
+ this._cellDragOver = null;
940
1075
  }
941
1076
  }}"
942
- @drop="${(e: DragEvent) => this._onSlotDrop(e, container.id, index)}"
1077
+ @drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, stack.length)}"
943
1078
  @click="${(e: Event) => {
944
1079
  e.stopPropagation();
945
- this._slotPicker = pickerOpen ? null : { containerId: container.id, index };
1080
+ this._cellPicker = pickerOpen ? null : { containerId: container.id, cellIndex };
946
1081
  }}">
947
- <zn-icon src="add" size="16"></zn-icon>
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>` : ''}
948
1091
  ${pickerOpen
949
- ? this._renderTypePicker(this._slotTypes(container), t => this.addSectionToSlot(t, container.id, index))
1092
+ ? this._renderTypePicker(this._cellTypes(container), t => this.addSectionToCell(t, container.id, cellIndex))
950
1093
  : ''}
951
1094
  </div>`;
952
1095
  }
953
1096
 
954
- /** Types allowed in a container's slots: non-containers, filtered by its accepts list. */
955
- private _slotTypes(container: PageSection): PageSectionType[] {
956
- const containerType = this.registry.get(container.type);
957
- return this.registry.all().filter(t =>
958
- !t.slots && (!containerType?.accepts || containerType.accepts.includes(t.type)));
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>`;
959
1107
  }
960
1108
 
961
- /** The one type-picker template both drop zones and slot cells render. */
1109
+ /** The one type-picker template both drop zones and container cells render. */
962
1110
  private _renderTypePicker(types: PageSectionType[], pick: (type: string) => void) {
963
1111
  return html`
964
1112
  <div class="picker" @click="${(e: Event) => e.stopPropagation()}">
@@ -968,7 +1116,6 @@ export default class ZnPageBuilder extends ZincElement {
968
1116
  class="picker__item"
969
1117
  @click="${() => {
970
1118
  this._pickerIndex = null;
971
- this._slotPicker = null;
972
1119
  pick(type.type);
973
1120
  }}">
974
1121
  <zn-icon src="${type.icon ?? 'widgets'}" library="${ifDefined(type.iconLibrary)}" size="14"></zn-icon>
@@ -1076,12 +1223,121 @@ export default class ZnPageBuilder extends ZincElement {
1076
1223
 
1077
1224
  private _updateSectionData(id: string, patch: Record<string, unknown>) {
1078
1225
  this._pushHistory();
1079
- this._commit({ sections: this._patchSection(id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1226
+ this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, data: { ...s.data, ...patch } })) });
1080
1227
  }
1081
1228
 
1082
1229
  private _renameSection(id: string, label: string) {
1083
1230
  this._pushHistory();
1084
- this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
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>`;
1085
1341
  }
1086
1342
 
1087
1343
  private _renderInspector() {
@@ -1120,6 +1376,7 @@ export default class ZnPageBuilder extends ZincElement {
1120
1376
  @zn-change="${this._onInspectorInput}"
1121
1377
  @input="${this._onInspectorInput}"
1122
1378
  @zn-input="${this._onInspectorInput}">
1379
+ ${this._renderLayoutGroup(section, type)}
1123
1380
  <zn-input
1124
1381
  class="inspector__rename"
1125
1382
  label="Section name"
@@ -1142,7 +1399,8 @@ export default class ZnPageBuilder extends ZincElement {
1142
1399
  class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
1143
1400
  @dragend="${() => {
1144
1401
  this._dragOverIndex = null;
1145
- this._slotDragOver = null;
1402
+ this._cellDragOver = null;
1403
+ this._draggingId = null;
1146
1404
  }}">
1147
1405
  <header part="header" class="header" ?hidden="${!hasHeader}">
1148
1406
  <div class="header__group">