@kubex/zinc 1.1.92 → 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.
- package/dist/custom-elements.json +2880 -453
- package/dist/vscode.html-custom-data.json +209 -22
- package/dist/web-types.json +452 -39
- package/dist/zn.d.ts +633 -61
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +865 -560
- package/docs/pages/components/page-builder.md +143 -19
- package/docs/pages/components/schedule-builder.md +345 -0
- package/docs/pages/components/slash-menu.md +132 -6
- package/docs/pages/components/textarea.md +16 -0
- package/package.json +1 -1
- package/scss/_root.scss +7 -1
- package/src/components/alert/alert.scss +9 -13
- package/src/components/button/button.scss +5 -2
- package/src/components/chip/chip.scss +1 -1
- package/src/components/icon-picker/icon-picker.component.ts +1 -1
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/linked-select/linked-select.component.ts +22 -5
- package/src/components/page/page.scss +20 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
- package/src/components/page-builder/page-builder.component.ts +535 -232
- package/src/components/page-builder/page-builder.scss +230 -19
- package/src/components/page-builder/page-builder.test.ts +790 -110
- package/src/components/page-builder/page-tree.test.ts +483 -0
- package/src/components/page-builder/page-tree.ts +329 -0
- package/src/components/page-builder/page.types.ts +98 -10
- package/src/components/page-nav/page-nav.scss +9 -1
- package/src/components/panel/panel.component.ts +5 -1
- package/src/components/priority-list/priority-list.component.ts +1 -0
- package/src/components/priority-list/priority-list.scss +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
- package/src/components/schedule-builder/index.ts +12 -0
- package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
- package/src/components/schedule-builder/schedule-builder.scss +448 -0
- package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
- package/src/components/settings-container/settings-container.scss +2 -1
- package/src/components/slash-item/slash-item.component.ts +1 -1
- package/src/components/slash-menu/slash-menu-items.ts +48 -0
- package/src/components/slash-menu/slash-menu.component.ts +134 -27
- package/src/components/slash-menu/slash-menu.scss +90 -12
- package/src/components/slash-menu/slash-menu.test.ts +107 -0
- package/src/components/textarea/textarea.component.ts +12 -2
- package/src/components/textarea/textarea.test.ts +2 -2
- package/src/components/toggle/toggle.component.ts +2 -1
- package/src/components/translations/translations.component.ts +5 -1
- package/src/zinc.ts +1 -0
- package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
- package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
|
@@ -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,
|
|
69
|
-
* palette entry and its content declares the inspector form for
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
*
|
|
@@ -77,6 +109,8 @@ function timeAgo(ms: number): string {
|
|
|
77
109
|
* @csspart palette - The left palette panel.
|
|
78
110
|
* @csspart canvas - The centre section-card canvas.
|
|
79
111
|
* @csspart inspector - The right panel while a section is selected.
|
|
112
|
+
* @csspart inspector-header - The inspector's fixed header (icon, section name, type, close).
|
|
113
|
+
* @csspart inspector-body - The inspector's scrolling form area.
|
|
80
114
|
*/
|
|
81
115
|
export default class ZnPageBuilder extends ZincElement {
|
|
82
116
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
@@ -87,6 +121,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
87
121
|
'zn-input': ZnInput,
|
|
88
122
|
'zn-page-palette-item': ZnPagePaletteItem,
|
|
89
123
|
'zn-page-section-card': ZnPageSectionCard,
|
|
124
|
+
'zn-toggle': ZnToggle,
|
|
90
125
|
};
|
|
91
126
|
|
|
92
127
|
private readonly formControlController = new FormControlController(this);
|
|
@@ -103,6 +138,14 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
103
138
|
@property({ reflect: true }) heading = '';
|
|
104
139
|
@property({ reflect: true }) subheading = '';
|
|
105
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
|
+
|
|
106
149
|
/** Section types to make available, registered into the internal registry. */
|
|
107
150
|
@property({ attribute: false }) sectionTypes: PageSectionType[] = [];
|
|
108
151
|
|
|
@@ -137,10 +180,10 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
137
180
|
/** Index of the drop zone whose "+" type picker is open, if any. */
|
|
138
181
|
@state() private _pickerIndex: number | null = null;
|
|
139
182
|
@state() private _dragOverIndex: number | null = null;
|
|
140
|
-
/** The
|
|
141
|
-
@state() private
|
|
142
|
-
/** The
|
|
143
|
-
@state() private
|
|
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;
|
|
144
187
|
/** The stamped config form for the selected section; rebuilt on selection change. */
|
|
145
188
|
@state() private _form: HTMLDivElement | null = null;
|
|
146
189
|
|
|
@@ -222,6 +265,15 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
222
265
|
}
|
|
223
266
|
}
|
|
224
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
|
+
|
|
225
277
|
@watch('sectionTypes')
|
|
226
278
|
handleSectionTypesChange() {
|
|
227
279
|
this.registry.registerAll(this.sectionTypes ?? []);
|
|
@@ -389,6 +441,18 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
389
441
|
const type = el.getAttribute('type');
|
|
390
442
|
if (!type) return null;
|
|
391
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
|
+
}
|
|
392
456
|
return {
|
|
393
457
|
type,
|
|
394
458
|
label: el.getAttribute('label') ?? type,
|
|
@@ -400,6 +464,9 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
400
464
|
configTemplate: el,
|
|
401
465
|
slots: slots > 0 ? slots : undefined,
|
|
402
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,
|
|
403
470
|
};
|
|
404
471
|
}
|
|
405
472
|
|
|
@@ -417,101 +484,74 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
417
484
|
|
|
418
485
|
/** Normalises and installs an externally provided state; resets selection. */
|
|
419
486
|
private _applyExternalState(next: PageState) {
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
// Nesting is one level deep by design — grandchildren in malformed input are
|
|
423
|
-
// dropped, which also bounds the recursion. Duplicate ids get regenerated so
|
|
424
|
-
// selection/patching can never target two sections at once, and children are
|
|
425
|
-
// capped (slots are single-digit by design; MAX_SECTIONS alone would still
|
|
426
|
-
// admit one section with a huge children array).
|
|
427
|
-
const normalise = (s: PageSection, depth: number): PageSection => {
|
|
428
|
-
const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
|
|
429
|
-
seen.add(id);
|
|
430
|
-
if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
|
|
431
|
-
return {
|
|
432
|
-
id,
|
|
433
|
-
type: s.type,
|
|
434
|
-
label: s.label,
|
|
435
|
-
data: structuredClone(s.data ?? {}),
|
|
436
|
-
...(s.children && depth === 0
|
|
437
|
-
? { children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null)) }
|
|
438
|
-
: {}),
|
|
439
|
-
};
|
|
440
|
-
};
|
|
441
|
-
const incoming = (next?.sections ?? []).filter(s => s && typeof s.type === 'string');
|
|
442
|
-
if (incoming.length > MAX_SECTIONS) {
|
|
443
|
-
console.warn(`<zn-page-builder> config has ${incoming.length} sections; keeping the first ${MAX_SECTIONS}`);
|
|
444
|
-
}
|
|
445
|
-
const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
|
|
446
|
-
if (clippedChildren) {
|
|
447
|
-
console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
|
|
448
|
-
}
|
|
487
|
+
const { sections, warnings } = normaliseSections(next?.sections, key => this.registry.get(key));
|
|
488
|
+
warnings.forEach(warning => console.warn(`<zn-page-builder> ${warning}`));
|
|
449
489
|
this._history = [];
|
|
450
490
|
this._redoStack = [];
|
|
451
|
-
this._state = { sections };
|
|
491
|
+
this._state = { sections: this._requireFirst(sections) };
|
|
452
492
|
this.defaultValue = JSON.stringify(this._state);
|
|
453
493
|
this._selectedId = null;
|
|
454
494
|
this._pickerIndex = null;
|
|
495
|
+
this._cellPicker = null;
|
|
455
496
|
this._offerRestoreIfNewer();
|
|
456
497
|
}
|
|
457
498
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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;
|
|
467
509
|
}
|
|
468
510
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
return this._state.sections.map(s => {
|
|
472
|
-
if (s.id === id) return patch(s);
|
|
473
|
-
if (s.children?.some(c => c?.id === id)) {
|
|
474
|
-
return { ...s, children: s.children.map(c => (c?.id === id ? patch(c) : c)) };
|
|
475
|
-
}
|
|
476
|
-
return s;
|
|
477
|
-
});
|
|
511
|
+
private _isPinned(id: string): boolean {
|
|
512
|
+
return this._pinnedId === id;
|
|
478
513
|
}
|
|
479
514
|
|
|
480
|
-
/**
|
|
481
|
-
private
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
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;
|
|
518
|
+
}
|
|
519
|
+
|
|
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;
|
|
498
532
|
}
|
|
499
533
|
|
|
500
|
-
/**
|
|
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
|
+
*/
|
|
501
541
|
private _commit(next: PageState) {
|
|
502
|
-
this._state = next;
|
|
542
|
+
this._state = { sections: normaliseGrowth(next.sections, key => this.registry.get(key)) };
|
|
503
543
|
this.emit('zn-page-change', { detail: { state: this.state } });
|
|
504
544
|
}
|
|
505
545
|
|
|
506
546
|
private _selectedSection(): PageSection | undefined {
|
|
507
|
-
return this.
|
|
547
|
+
return findSection(this._state.sections, this._selectedId);
|
|
508
548
|
}
|
|
509
549
|
|
|
510
550
|
private _select(id: string | null) {
|
|
511
551
|
if (this._selectedId === id) return;
|
|
512
552
|
this._selectedId = id;
|
|
513
553
|
this._pickerIndex = null;
|
|
514
|
-
this.
|
|
554
|
+
this._cellPicker = null;
|
|
515
555
|
this.emit('zn-page-selection-change', { detail: { sectionId: id } });
|
|
516
556
|
}
|
|
517
557
|
|
|
@@ -543,44 +583,38 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
543
583
|
|
|
544
584
|
// --- Section mutations ------------------------------------------------------
|
|
545
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
|
+
|
|
546
597
|
/** Adds a section of a registered type at `index` (default: end). Returns null for unknown types. */
|
|
547
598
|
addSection(type: string, index?: number): PageSection | null {
|
|
548
599
|
if (!this.registry.has(type)) return null;
|
|
549
600
|
this._pushHistory();
|
|
550
|
-
const section
|
|
601
|
+
const section = this._newSection(type);
|
|
551
602
|
const sections = [...this._state.sections];
|
|
552
|
-
sections.splice(index ?? sections.length, 0, section);
|
|
603
|
+
sections.splice(Math.max(index ?? sections.length, this._firstFreeIndex), 0, section);
|
|
553
604
|
this._commit({ sections });
|
|
554
605
|
this._select(section.id);
|
|
555
606
|
return section;
|
|
556
607
|
}
|
|
557
608
|
|
|
558
|
-
/** Adds a new section of a registered type into a container's slot. Returns null if not allowed. */
|
|
559
|
-
addSectionToSlot(type: string, containerId: string, slotIndex: number): PageSection | null {
|
|
560
|
-
const sectionType = this.registry.get(type);
|
|
561
|
-
const container = this._findSection(containerId);
|
|
562
|
-
const containerType = container ? this.registry.get(container.type) : undefined;
|
|
563
|
-
if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
|
|
564
|
-
if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
|
|
565
|
-
if (containerType.accepts && !containerType.accepts.includes(type)) return null;
|
|
566
|
-
const children = sectionChildren(container, containerType);
|
|
567
|
-
if (children[slotIndex]) return null;
|
|
568
|
-
const section: PageSection = { id: generateSectionId(), type, data: {} };
|
|
569
|
-
children[slotIndex] = section;
|
|
570
|
-
this._pushHistory();
|
|
571
|
-
this._commit({ sections: this._patchSection(containerId, s => ({ ...s, children })) });
|
|
572
|
-
this._select(section.id);
|
|
573
|
-
return section;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
609
|
private _removeSection(id: string) {
|
|
577
|
-
|
|
610
|
+
if (this._isPinned(id)) return;
|
|
611
|
+
const [removed, sections] = extractSection(this._state.sections, id);
|
|
578
612
|
if (!removed) return;
|
|
579
613
|
this._pushHistory();
|
|
580
|
-
// Clear selection for the removed section AND anything inside it.
|
|
581
|
-
|
|
582
|
-
this.
|
|
583
|
-
|
|
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);
|
|
584
618
|
this._commit({ sections });
|
|
585
619
|
}
|
|
586
620
|
|
|
@@ -588,36 +622,54 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
588
622
|
const index = this._state.sections.findIndex(s => s.id === id);
|
|
589
623
|
if (index !== -1) {
|
|
590
624
|
this._pushHistory();
|
|
591
|
-
const copy =
|
|
592
|
-
copy.id = generateSectionId();
|
|
593
|
-
copy.children = copy.children?.map(c => (c ? { ...c, id: generateSectionId() } : null));
|
|
625
|
+
const copy = cloneWithNewIds(this._state.sections[index]);
|
|
594
626
|
const sections = [...this._state.sections];
|
|
595
627
|
sections.splice(index + 1, 0, copy);
|
|
596
628
|
this._commit({ sections });
|
|
597
629
|
this._select(copy.id);
|
|
598
630
|
return;
|
|
599
631
|
}
|
|
600
|
-
// A
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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);
|
|
617
667
|
}
|
|
618
668
|
|
|
619
|
-
/** Moves a section (top-level or
|
|
669
|
+
/** Moves a section (top-level or inside a cell) to a top-level position. */
|
|
620
670
|
private _moveSection(id: string, index: number) {
|
|
671
|
+
if (this._isPinned(id)) return;
|
|
672
|
+
index = Math.max(index, this._firstFreeIndex);
|
|
621
673
|
const from = this._state.sections.findIndex(s => s.id === id);
|
|
622
674
|
if (from !== -1) {
|
|
623
675
|
const to = index > from ? index - 1 : index;
|
|
@@ -629,59 +681,22 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
629
681
|
this._commit({ sections });
|
|
630
682
|
return;
|
|
631
683
|
}
|
|
632
|
-
const [moved, sections] = this.
|
|
684
|
+
const [moved, sections] = extractSection(this._state.sections, id);
|
|
633
685
|
if (!moved) return;
|
|
634
686
|
this._pushHistory();
|
|
635
687
|
sections.splice(index, 0, moved);
|
|
636
688
|
this._commit({ sections });
|
|
637
689
|
}
|
|
638
690
|
|
|
639
|
-
/**
|
|
640
|
-
* Moves a section into a container's slot. Dropping onto an occupied slot swaps
|
|
641
|
-
* the two children (slot-to-slot reordering); top-level sections and containers
|
|
642
|
-
* only enter empty slots / never enter slots respectively.
|
|
643
|
-
*/
|
|
644
|
-
private _moveToSlot(id: string, containerId: string, slotIndex: number) {
|
|
645
|
-
const moved = this._findSection(id);
|
|
646
|
-
const container = this._findSection(containerId);
|
|
647
|
-
const containerType = container ? this.registry.get(container.type) : undefined;
|
|
648
|
-
if (!moved || !container || !containerType?.slots || id === containerId) return;
|
|
649
|
-
if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
|
|
650
|
-
if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
|
|
651
|
-
if (slotIndex < 0 || slotIndex >= containerType.slots) return;
|
|
652
|
-
|
|
653
|
-
const target = container.children?.[slotIndex] ?? null;
|
|
654
|
-
if (target?.id === id) return;
|
|
655
|
-
const fromTop = this._state.sections.some(s => s.id === id);
|
|
656
|
-
if (target && fromTop) return; // top-level sections only drop on empty slots
|
|
657
|
-
|
|
658
|
-
this._pushHistory();
|
|
659
|
-
const sections = structuredClone(this._state.sections);
|
|
660
|
-
const containerRef = sections.find(s => s.id === containerId)!;
|
|
661
|
-
containerRef.children = Array.from({ length: containerType.slots }, (_, i) => containerRef.children?.[i] ?? null);
|
|
662
|
-
const movedCopy = structuredClone(moved);
|
|
663
|
-
|
|
664
|
-
if (fromTop) {
|
|
665
|
-
sections.splice(sections.findIndex(s => s.id === id), 1);
|
|
666
|
-
} else {
|
|
667
|
-
// Swap: the occupant (or null) takes the source slot.
|
|
668
|
-
for (const s of sections) {
|
|
669
|
-
const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
|
|
670
|
-
if (slot !== -1) {
|
|
671
|
-
s.children![slot] = target ? structuredClone(target) : null;
|
|
672
|
-
break;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
containerRef.children[slotIndex] = movedCopy;
|
|
677
|
-
this._commit({ sections });
|
|
678
|
-
}
|
|
679
|
-
|
|
680
691
|
// --- Canvas drag & drop -----------------------------------------------------
|
|
681
692
|
|
|
693
|
+
/** Id of the section currently being dragged — dataTransfer is unreadable during dragover. */
|
|
694
|
+
private _draggingId: string | null = null;
|
|
695
|
+
|
|
682
696
|
private _onCardDragStart(e: DragEvent, id: string) {
|
|
683
697
|
if (!e.dataTransfer) return;
|
|
684
698
|
e.stopPropagation(); // a child card's drag must not also start its container's
|
|
699
|
+
this._draggingId = id;
|
|
685
700
|
e.dataTransfer.setData(PAGE_SECTION_MIME, id);
|
|
686
701
|
e.dataTransfer.effectAllowed = 'move';
|
|
687
702
|
}
|
|
@@ -692,24 +707,6 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
692
707
|
return types.includes(PAGE_TYPE_MIME) || types.includes(PAGE_SECTION_MIME);
|
|
693
708
|
}
|
|
694
709
|
|
|
695
|
-
private _onSlotDragOver(e: DragEvent, containerId: string, index: number) {
|
|
696
|
-
if (!this._isPageDrag(e)) return;
|
|
697
|
-
e.preventDefault();
|
|
698
|
-
e.stopPropagation();
|
|
699
|
-
this._dragOverIndex = null;
|
|
700
|
-
this._slotDragOver = { containerId, index };
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
private _onSlotDrop(e: DragEvent, containerId: string, index: number) {
|
|
704
|
-
e.preventDefault();
|
|
705
|
-
e.stopPropagation();
|
|
706
|
-
this._slotDragOver = null;
|
|
707
|
-
const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
|
|
708
|
-
const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
|
|
709
|
-
if (typeKey) this.addSectionToSlot(typeKey, containerId, index);
|
|
710
|
-
else if (sectionId) this._moveToSlot(sectionId, containerId, index);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
710
|
private _onZoneDragOver(e: DragEvent, index: number) {
|
|
714
711
|
if (!this._isPageDrag(e)) return;
|
|
715
712
|
e.preventDefault();
|
|
@@ -739,6 +736,124 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
739
736
|
this._onZoneDrop(e, this._state.sections.length);
|
|
740
737
|
};
|
|
741
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
|
+
|
|
742
857
|
private _onCardKeydown(e: KeyboardEvent, id: string) {
|
|
743
858
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
744
859
|
e.preventDefault();
|
|
@@ -864,7 +979,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
864
979
|
Drag sections here to build your page
|
|
865
980
|
</div>` : ''}
|
|
866
981
|
${sections.map((section, i) => html`
|
|
867
|
-
${this._renderDropZone(i)}
|
|
982
|
+
${i === 0 && this._pinnedId !== null ? '' : this._renderDropZone(i)}
|
|
868
983
|
${this._renderCard(section, i)}
|
|
869
984
|
`)}
|
|
870
985
|
${this._renderDropZone(sections.length)}
|
|
@@ -879,10 +994,11 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
879
994
|
extraClass = ''
|
|
880
995
|
) {
|
|
881
996
|
const type = this.registry.get(section.type);
|
|
997
|
+
const pinned = this._isPinned(section.id);
|
|
882
998
|
return html`
|
|
883
999
|
<zn-page-section-card
|
|
884
1000
|
class="${extraClass}"
|
|
885
|
-
draggable="true"
|
|
1001
|
+
draggable="${pinned ? 'false' : 'true'}"
|
|
886
1002
|
tabindex="0"
|
|
887
1003
|
label="${section.label ?? type?.label ?? section.type}"
|
|
888
1004
|
summary="${type ? sectionSummary(section, type) : `Unknown type "${section.type}"`}"
|
|
@@ -891,6 +1007,7 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
891
1007
|
color="${ifDefined(type?.color)}"
|
|
892
1008
|
?selected="${this._selectedId === section.id}"
|
|
893
1009
|
?unknown="${!type}"
|
|
1010
|
+
?locked="${pinned}"
|
|
894
1011
|
@click="${(e: Event) => {
|
|
895
1012
|
e.stopPropagation();
|
|
896
1013
|
this._select(section.id);
|
|
@@ -904,59 +1021,92 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
904
1021
|
}
|
|
905
1022
|
|
|
906
1023
|
private _renderCard(section: PageSection, index: number) {
|
|
907
|
-
|
|
908
|
-
const card = this._renderSectionCard(section, {
|
|
1024
|
+
return this._renderNode(section, {
|
|
909
1025
|
over: e => this._onZoneDragOver(e, index + 1),
|
|
910
1026
|
drop: e => this._onZoneDrop(e, index + 1),
|
|
911
1027
|
});
|
|
912
|
-
|
|
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;
|
|
913
1042
|
return html`
|
|
914
1043
|
<div class="container">
|
|
915
1044
|
${card}
|
|
916
|
-
|
|
917
|
-
${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
|
|
918
|
-
</div>
|
|
1045
|
+
${this._renderCells(section, type)}
|
|
919
1046
|
</div>`;
|
|
920
1047
|
}
|
|
921
1048
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
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;
|
|
931
1068
|
return html`
|
|
932
1069
|
<div
|
|
933
|
-
class="
|
|
934
|
-
@dragover="${(e: DragEvent) => this.
|
|
1070
|
+
class="cell ${active ? 'cell--active' : ''} ${stack.length ? '' : 'cell--empty'}"
|
|
1071
|
+
@dragover="${(e: DragEvent) => this._onCellDragOver(e, container.id, cellIndex, stack.length)}"
|
|
935
1072
|
@dragleave="${() => {
|
|
936
|
-
if (this.
|
|
937
|
-
this.
|
|
1073
|
+
if (this._cellDragOver?.containerId === container.id && this._cellDragOver.cellIndex === cellIndex) {
|
|
1074
|
+
this._cellDragOver = null;
|
|
938
1075
|
}
|
|
939
1076
|
}}"
|
|
940
|
-
@drop="${(e: DragEvent) => this.
|
|
1077
|
+
@drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, stack.length)}"
|
|
941
1078
|
@click="${(e: Event) => {
|
|
942
1079
|
e.stopPropagation();
|
|
943
|
-
this.
|
|
1080
|
+
this._cellPicker = pickerOpen ? null : { containerId: container.id, cellIndex };
|
|
944
1081
|
}}">
|
|
945
|
-
|
|
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>` : ''}
|
|
946
1091
|
${pickerOpen
|
|
947
|
-
? this._renderTypePicker(this.
|
|
1092
|
+
? this._renderTypePicker(this._cellTypes(container), t => this.addSectionToCell(t, container.id, cellIndex))
|
|
948
1093
|
: ''}
|
|
949
1094
|
</div>`;
|
|
950
1095
|
}
|
|
951
1096
|
|
|
952
|
-
/**
|
|
953
|
-
private
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
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>`;
|
|
957
1107
|
}
|
|
958
1108
|
|
|
959
|
-
/** The one type-picker template both drop zones and
|
|
1109
|
+
/** The one type-picker template both drop zones and container cells render. */
|
|
960
1110
|
private _renderTypePicker(types: PageSectionType[], pick: (type: string) => void) {
|
|
961
1111
|
return html`
|
|
962
1112
|
<div class="picker" @click="${(e: Event) => e.stopPropagation()}">
|
|
@@ -966,7 +1116,6 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
966
1116
|
class="picker__item"
|
|
967
1117
|
@click="${() => {
|
|
968
1118
|
this._pickerIndex = null;
|
|
969
|
-
this._slotPicker = null;
|
|
970
1119
|
pick(type.type);
|
|
971
1120
|
}}">
|
|
972
1121
|
<zn-icon src="${type.icon ?? 'widgets'}" library="${ifDefined(type.iconLibrary)}" size="14"></zn-icon>
|
|
@@ -1017,6 +1166,21 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1017
1166
|
const form = document.createElement('div');
|
|
1018
1167
|
form.className = 'inspector__form';
|
|
1019
1168
|
form.append(type.configTemplate.content.cloneNode(true));
|
|
1169
|
+
// Custom elements cloned out of a <template> are inert until they are
|
|
1170
|
+
// connected, so their accessors do not exist yet. Assigning `value` to one
|
|
1171
|
+
// would define an own property that permanently shadows the setter the
|
|
1172
|
+
// element defines on upgrade — Lit only replays properties it declares
|
|
1173
|
+
// reactive, so a hand-written `value` accessor (zn-icon-picker) would keep
|
|
1174
|
+
// its default and the control would render empty. Upgrade first.
|
|
1175
|
+
//
|
|
1176
|
+
// Toggles default to a stacked label, which reads badly in a narrow panel —
|
|
1177
|
+
// put the switch on the label's right unless the host asked for a position.
|
|
1178
|
+
// Set before the upgrade, while `label-position` can only be an authored
|
|
1179
|
+
// attribute and never one zn-toggle reflected from its own default.
|
|
1180
|
+
form.querySelectorAll<HTMLElement>('zn-toggle:not([label-position])').forEach(toggle => {
|
|
1181
|
+
toggle.setAttribute('label-position', 'left');
|
|
1182
|
+
});
|
|
1183
|
+
customElements.upgrade(form);
|
|
1020
1184
|
form.querySelectorAll<HTMLElement>('[name]').forEach(control => {
|
|
1021
1185
|
const name = control.getAttribute('name')!;
|
|
1022
1186
|
const value = section.data[name];
|
|
@@ -1029,7 +1193,9 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1029
1193
|
(control as HTMLInputElement).value = String(value);
|
|
1030
1194
|
}
|
|
1031
1195
|
});
|
|
1032
|
-
|
|
1196
|
+
// A content-free template stamps no controls — leave the form null so the
|
|
1197
|
+
// inspector shows its "no settings" hint rather than a blank gap.
|
|
1198
|
+
this._form = form.childElementCount ? form : null;
|
|
1033
1199
|
}
|
|
1034
1200
|
|
|
1035
1201
|
private _isBooleanControl(control: HTMLElement, value: unknown): boolean {
|
|
@@ -1057,34 +1223,170 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1057
1223
|
|
|
1058
1224
|
private _updateSectionData(id: string, patch: Record<string, unknown>) {
|
|
1059
1225
|
this._pushHistory();
|
|
1060
|
-
this._commit({ sections: this.
|
|
1226
|
+
this._commit({ sections: patchSection(this._state.sections, id, s => ({ ...s, data: { ...s.data, ...patch } })) });
|
|
1061
1227
|
}
|
|
1062
1228
|
|
|
1063
1229
|
private _renameSection(id: string, label: string) {
|
|
1064
1230
|
this._pushHistory();
|
|
1065
|
-
this._commit({ sections: this.
|
|
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>`;
|
|
1066
1341
|
}
|
|
1067
1342
|
|
|
1068
1343
|
private _renderInspector() {
|
|
1069
1344
|
const section = this._selectedSection();
|
|
1070
1345
|
if (!section) return html``;
|
|
1071
1346
|
const type = this.registry.get(section.type);
|
|
1347
|
+
const hasConfig = Boolean(type?.renderConfig) || this._form !== null;
|
|
1348
|
+
const title = section.label ?? type?.label ?? section.type;
|
|
1349
|
+
const typeLabel = type?.label ?? section.type;
|
|
1072
1350
|
return html`
|
|
1073
1351
|
<aside part="inspector" class="inspector">
|
|
1074
|
-
<
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1352
|
+
<div part="inspector-header" class="inspector-head">
|
|
1353
|
+
<span
|
|
1354
|
+
class="inspector-head__icon"
|
|
1355
|
+
style="--section-accent:${type?.color ?? 'rgb(var(--zn-color-primary))'}">
|
|
1356
|
+
<zn-icon src="${type?.icon ?? 'widgets'}" library="${ifDefined(type?.iconLibrary)}" size="18"></zn-icon>
|
|
1357
|
+
</span>
|
|
1358
|
+
<div class="inspector-head__text">
|
|
1359
|
+
<div class="inspector-head__title">${title}</div>
|
|
1360
|
+
${title === typeLabel ? '' : html`
|
|
1361
|
+
<div class="inspector-head__type">${typeLabel}</div>`}
|
|
1362
|
+
</div>
|
|
1363
|
+
<button
|
|
1364
|
+
type="button"
|
|
1365
|
+
class="inspector-close"
|
|
1366
|
+
title="Close section settings"
|
|
1367
|
+
aria-label="Close section settings"
|
|
1368
|
+
@click="${() => this._select(null)}">
|
|
1369
|
+
<zn-icon src="x@lu" size="18"></zn-icon>
|
|
1370
|
+
</button>
|
|
1371
|
+
</div>
|
|
1079
1372
|
<div
|
|
1080
|
-
|
|
1373
|
+
part="inspector-body"
|
|
1374
|
+
class="inspector-body"
|
|
1081
1375
|
@change="${this._onInspectorInput}"
|
|
1082
1376
|
@zn-change="${this._onInspectorInput}"
|
|
1083
1377
|
@input="${this._onInspectorInput}"
|
|
1084
1378
|
@zn-input="${this._onInspectorInput}">
|
|
1379
|
+
${this._renderLayoutGroup(section, type)}
|
|
1380
|
+
<zn-input
|
|
1381
|
+
class="inspector__rename"
|
|
1382
|
+
label="Section name"
|
|
1383
|
+
.value="${section.label ?? type?.label ?? ''}"
|
|
1384
|
+
@zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
|
|
1085
1385
|
${type?.renderConfig
|
|
1086
1386
|
? type.renderConfig(section, data => this._updateSectionData(section.id, data))
|
|
1087
1387
|
: this._form}
|
|
1388
|
+
${hasConfig ? '' : html`
|
|
1389
|
+
<p class="inspector-hint">This section type has no settings.</p>`}
|
|
1088
1390
|
</div>
|
|
1089
1391
|
</aside>`;
|
|
1090
1392
|
}
|
|
@@ -1097,7 +1399,8 @@ export default class ZnPageBuilder extends ZincElement {
|
|
|
1097
1399
|
class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
|
|
1098
1400
|
@dragend="${() => {
|
|
1099
1401
|
this._dragOverIndex = null;
|
|
1100
|
-
this.
|
|
1402
|
+
this._cellDragOver = null;
|
|
1403
|
+
this._draggingId = null;
|
|
1101
1404
|
}}">
|
|
1102
1405
|
<header part="header" class="header" ?hidden="${!hasHeader}">
|
|
1103
1406
|
<div class="header__group">
|