@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.
- package/dist/custom-elements.json +995 -196
- package/dist/vscode.html-custom-data.json +74 -19
- package/dist/web-types.json +147 -37
- package/dist/zn.d.ts +335 -59
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +467 -355
- package/docs/pages/components/page-builder.md +121 -19
- 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/button/button.scss +5 -2
- package/src/components/inline-edit/inline-edit.component.ts +6 -1
- package/src/components/input/input.component.ts +12 -2
- package/src/components/page/page.scss +7 -2
- 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 +483 -225
- package/src/components/page-builder/page-builder.scss +64 -10
- package/src/components/page-builder/page-builder.test.ts +656 -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 +75 -7
- 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/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/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,
|
|
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
|
*
|
|
@@ -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
|
|
143
|
-
@state() private
|
|
144
|
-
/** The
|
|
145
|
-
@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;
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
/**
|
|
483
|
-
private
|
|
484
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
this.
|
|
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 =
|
|
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
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
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="
|
|
936
|
-
@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)}"
|
|
937
1072
|
@dragleave="${() => {
|
|
938
|
-
if (this.
|
|
939
|
-
this.
|
|
1073
|
+
if (this._cellDragOver?.containerId === container.id && this._cellDragOver.cellIndex === cellIndex) {
|
|
1074
|
+
this._cellDragOver = null;
|
|
940
1075
|
}
|
|
941
1076
|
}}"
|
|
942
|
-
@drop="${(e: DragEvent) => this.
|
|
1077
|
+
@drop="${(e: DragEvent) => this._onCellDrop(e, container.id, cellIndex, stack.length)}"
|
|
943
1078
|
@click="${(e: Event) => {
|
|
944
1079
|
e.stopPropagation();
|
|
945
|
-
this.
|
|
1080
|
+
this._cellPicker = pickerOpen ? null : { containerId: container.id, cellIndex };
|
|
946
1081
|
}}">
|
|
947
|
-
|
|
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.
|
|
1092
|
+
? this._renderTypePicker(this._cellTypes(container), t => this.addSectionToCell(t, container.id, cellIndex))
|
|
950
1093
|
: ''}
|
|
951
1094
|
</div>`;
|
|
952
1095
|
}
|
|
953
1096
|
|
|
954
|
-
/**
|
|
955
|
-
private
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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">
|