@kubex/zinc 1.1.28 → 1.1.29
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/custom-elements-manifest.config.js +2 -2
- package/dist/custom-elements.json +1483 -17
- package/dist/vscode.html-custom-data.json +40 -1
- package/dist/web-types.json +124 -3
- package/dist/zn.d.ts +670 -14
- package/dist/zn.min.js +1305 -925
- package/docs/pages/components/flow-builder-troubleshooter-demo.njk +106 -78
- package/docs/pages/components/flow-builder.md +422 -66
- package/docs/pages/components/page-builder.md +167 -0
- package/package.json +1 -1
- package/src/components/datepicker/datepicker.scss +0 -10
- package/src/components/flow-builder/flow-builder.component.ts +377 -16
- package/src/components/flow-builder/flow-builder.scss +398 -250
- package/src/components/flow-builder/flow-builder.test.ts +241 -4
- package/src/components/flow-builder/flow-layout.ts +42 -17
- package/src/components/flow-builder/flow.types.ts +168 -43
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.component.ts +300 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.scss +222 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/flow-branch-conditions.test.ts +125 -0
- package/src/components/flow-builder/modules/flow-branch-conditions/index.ts +12 -0
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +184 -26
- package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +170 -120
- package/src/components/flow-builder/modules/flow-node/flow-node.scss +42 -42
- package/src/components/flow-builder/modules/flow-step/flow-step.component.ts +2 -1
- package/src/components/flow-builder/modules/flow-step/flow-step.scss +25 -17
- package/src/components/icon-picker/icon-picker.component.ts +20 -37
- package/src/components/icon-picker/icon-picker.scss +5 -45
- package/src/components/page-builder/index.ts +14 -0
- package/src/components/page-builder/modules/page-palette-item/index.ts +12 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.component.ts +71 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.scss +70 -0
- package/src/components/page-builder/modules/page-palette-item/page-palette-item.test.ts +20 -0
- package/src/components/page-builder/modules/page-section-card/index.ts +12 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +93 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.scss +92 -0
- package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +50 -0
- package/src/components/page-builder/page-builder.component.ts +1053 -0
- package/src/components/page-builder/page-builder.scss +494 -0
- package/src/components/page-builder/page-builder.test.ts +464 -0
- package/src/components/page-builder/page-registry.ts +48 -0
- package/src/components/page-builder/page.types.ts +75 -0
- package/src/events/events.ts +2 -0
- package/src/events/zn-page-change.ts +9 -0
- package/src/events/zn-page-selection-change.ts +7 -0
- package/src/zinc.ts +4 -0
- package/web-test-runner.config.js +6 -1
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
|
|
2
|
+
import {
|
|
3
|
+
emptyPageState,
|
|
4
|
+
generateSectionId,
|
|
5
|
+
PAGE_SECTION_MIME,
|
|
6
|
+
PAGE_TYPE_MIME,
|
|
7
|
+
type PageSection,
|
|
8
|
+
type PageSectionType,
|
|
9
|
+
type PageState,
|
|
10
|
+
sectionChildren,
|
|
11
|
+
sectionSummary,
|
|
12
|
+
} from './page.types';
|
|
13
|
+
import {HasSlotController} from '../../internal/slot';
|
|
14
|
+
import {ifDefined} from 'lit/directives/if-defined.js';
|
|
15
|
+
import {PageSectionRegistry} from './page-registry';
|
|
16
|
+
import {property, state} from 'lit/decorators.js';
|
|
17
|
+
import {watch} from '../../internal/watch';
|
|
18
|
+
import ZincElement from '../../internal/zinc-element';
|
|
19
|
+
import ZnCollapsible from '../collapsible';
|
|
20
|
+
import ZnIcon from '../icon';
|
|
21
|
+
import ZnInput from '../input';
|
|
22
|
+
import ZnPagePaletteItem from './modules/page-palette-item';
|
|
23
|
+
import ZnPageSectionCard from './modules/page-section-card';
|
|
24
|
+
|
|
25
|
+
import styles from './page-builder.scss';
|
|
26
|
+
|
|
27
|
+
const HISTORY_LIMIT = 50;
|
|
28
|
+
/** Sections beyond this are dropped (with a warning) when external state is applied. */
|
|
29
|
+
const MAX_SECTIONS = 500;
|
|
30
|
+
/** Per-container children beyond this are dropped when external state is applied. */
|
|
31
|
+
const MAX_CHILDREN = 24;
|
|
32
|
+
/** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
|
|
33
|
+
const NARROW_WIDTH = 768;
|
|
34
|
+
|
|
35
|
+
const AUTO_SAVE_DEFAULT_MINUTES = 5;
|
|
36
|
+
const AUTO_SAVE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** Compact relative time for the auto-save status ("just now", "3m ago"). */
|
|
39
|
+
function timeAgo(ms: number): string {
|
|
40
|
+
const s = Math.floor(ms / 1000);
|
|
41
|
+
if (s < 10) return 'just now';
|
|
42
|
+
if (s < 60) return `${s}s ago`;
|
|
43
|
+
const m = Math.floor(s / 60);
|
|
44
|
+
if (m < 60) return `${m}m ago`;
|
|
45
|
+
const h = Math.floor(m / 60);
|
|
46
|
+
if (h < 24) return `${h}h ago`;
|
|
47
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @summary A config-driven page composer: a palette of predefined section types, a linear
|
|
52
|
+
* canvas of section cards, and an inspector for editing each section's content.
|
|
53
|
+
* @documentation https://zinc.style/components/page-builder
|
|
54
|
+
* @status experimental
|
|
55
|
+
* @since 1.0
|
|
56
|
+
*
|
|
57
|
+
* @dependency zn-collapsible
|
|
58
|
+
* @dependency zn-icon
|
|
59
|
+
* @dependency zn-input
|
|
60
|
+
* @dependency zn-page-palette-item
|
|
61
|
+
* @dependency zn-page-section-card
|
|
62
|
+
*
|
|
63
|
+
* @event zn-page-change - Emitted whenever the page state changes. `event.detail.state` is the new PageState.
|
|
64
|
+
* @event zn-page-selection-change - Emitted when the selected section changes. `event.detail.sectionId`.
|
|
65
|
+
*
|
|
66
|
+
* @slot config - `<template type="…">` declarations; never displayed. Each template's attributes
|
|
67
|
+
* (type, label, icon, icon-library, color, category, description, slots, accepts) declare a
|
|
68
|
+
* palette entry and its content declares the inspector form for that type. `slots` makes the
|
|
69
|
+
* section a container with that many child slots; `accepts` is a comma-separated list of the
|
|
70
|
+
* type keys its slots allow.
|
|
71
|
+
* @slot header-left - Actions shown on the left of the header bar.
|
|
72
|
+
* @slot header-right - Actions shown on the right of the header bar.
|
|
73
|
+
*
|
|
74
|
+
* @csspart base - The grid wrapper.
|
|
75
|
+
* @csspart header - The full-width header action bar (only rendered when header slots are filled).
|
|
76
|
+
* @csspart palette - The left palette panel.
|
|
77
|
+
* @csspart canvas - The centre section-card canvas.
|
|
78
|
+
* @csspart inspector - The right panel while a section is selected.
|
|
79
|
+
*/
|
|
80
|
+
export default class ZnPageBuilder extends ZincElement {
|
|
81
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
82
|
+
|
|
83
|
+
static dependencies = {
|
|
84
|
+
'zn-collapsible': ZnCollapsible,
|
|
85
|
+
'zn-icon': ZnIcon,
|
|
86
|
+
'zn-input': ZnInput,
|
|
87
|
+
'zn-page-palette-item': ZnPagePaletteItem,
|
|
88
|
+
'zn-page-section-card': ZnPageSectionCard,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The page state as a JSON string. Parsed on set; invalid JSON is ignored with a warning. */
|
|
92
|
+
@property() config = '';
|
|
93
|
+
|
|
94
|
+
@property({reflect: true}) heading = '';
|
|
95
|
+
@property({reflect: true}) subheading = '';
|
|
96
|
+
|
|
97
|
+
/** Section types to make available, registered into the internal registry. */
|
|
98
|
+
@property({attribute: false}) sectionTypes: PageSectionType[] = [];
|
|
99
|
+
|
|
100
|
+
/** Collapses the left palette. Auto-set when the builder becomes narrow. */
|
|
101
|
+
@property({type: Boolean, reflect: true, attribute: 'palette-collapsed'}) paletteCollapsed = false;
|
|
102
|
+
|
|
103
|
+
/** Collapses the inspector while a section is selected. */
|
|
104
|
+
@property({type: Boolean, reflect: true, attribute: 'inspector-collapsed'}) inspectorCollapsed = false;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Auto-save the page to localStorage (1-day TTL). Omit to disable. A bare
|
|
108
|
+
* `auto-save` saves every 5 minutes; a numeric value sets the interval in
|
|
109
|
+
* minutes (`auto-save="2"`). Restore with `restoreAutoSave()`.
|
|
110
|
+
*/
|
|
111
|
+
@property({
|
|
112
|
+
attribute: 'auto-save',
|
|
113
|
+
converter: {
|
|
114
|
+
fromAttribute: (value: string | null) => {
|
|
115
|
+
if (value === null) return null;
|
|
116
|
+
const minutes = parseFloat(value);
|
|
117
|
+
return Number.isFinite(minutes) && minutes > 0 ? minutes : AUTO_SAVE_DEFAULT_MINUTES;
|
|
118
|
+
},
|
|
119
|
+
toAttribute: (value: number | null) => (value === null ? null : String(value)),
|
|
120
|
+
},
|
|
121
|
+
}) autoSave: number | null = null;
|
|
122
|
+
|
|
123
|
+
private registry = new PageSectionRegistry();
|
|
124
|
+
|
|
125
|
+
@state() private _state: PageState = emptyPageState();
|
|
126
|
+
@state() private _selectedId: string | null = null;
|
|
127
|
+
@state() private _search = '';
|
|
128
|
+
/** Index of the drop zone whose "+" type picker is open, if any. */
|
|
129
|
+
@state() private _pickerIndex: number | null = null;
|
|
130
|
+
@state() private _dragOverIndex: number | null = null;
|
|
131
|
+
/** The container slot a drag is currently over, if any. */
|
|
132
|
+
@state() private _slotDragOver: {containerId: string; index: number} | null = null;
|
|
133
|
+
/** The container slot whose "+" type picker is open, if any. */
|
|
134
|
+
@state() private _slotPicker: {containerId: string; index: number} | null = null;
|
|
135
|
+
/** The stamped config form for the selected section; rebuilt on selection change. */
|
|
136
|
+
@state() private _form: HTMLDivElement | null = null;
|
|
137
|
+
|
|
138
|
+
private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
|
|
139
|
+
|
|
140
|
+
private _history: PageState[] = [];
|
|
141
|
+
private _redoStack: PageState[] = [];
|
|
142
|
+
|
|
143
|
+
/** A deep copy of the current page state. */
|
|
144
|
+
get state(): PageState {
|
|
145
|
+
return structuredClone(this._state);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Replaces the page state wholesale (does not emit zn-page-change). */
|
|
149
|
+
set state(next: PageState) {
|
|
150
|
+
try {
|
|
151
|
+
this._applyExternalState(next);
|
|
152
|
+
} catch {
|
|
153
|
+
console.warn('<zn-page-builder> invalid state');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@watch('config')
|
|
158
|
+
handleConfigChange() {
|
|
159
|
+
if (!this.config) return;
|
|
160
|
+
try {
|
|
161
|
+
this._applyExternalState(JSON.parse(this.config) as PageState);
|
|
162
|
+
} catch {
|
|
163
|
+
console.warn('<zn-page-builder> invalid config JSON');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@watch('sectionTypes')
|
|
168
|
+
handleSectionTypesChange() {
|
|
169
|
+
this.registry.registerAll(this.sectionTypes ?? []);
|
|
170
|
+
this.requestUpdate();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
registerSectionType(type: PageSectionType): this {
|
|
174
|
+
this.registry.register(type);
|
|
175
|
+
this.requestUpdate();
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registerSectionTypes(types: PageSectionType[]): this {
|
|
180
|
+
this.registry.registerAll(types);
|
|
181
|
+
this.requestUpdate();
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Auto-collapses the palette when the builder crosses into narrow — only on the
|
|
187
|
+
* crossing, so re-expanding while narrow stays a user choice.
|
|
188
|
+
*/
|
|
189
|
+
private _wasNarrow = false;
|
|
190
|
+
|
|
191
|
+
private _resizeObserver = new ResizeObserver(entries => {
|
|
192
|
+
const width = entries[0]?.contentRect.width ?? 0;
|
|
193
|
+
if (width <= 0) return;
|
|
194
|
+
const narrow = width < NARROW_WIDTH;
|
|
195
|
+
if (narrow && !this._wasNarrow) this.paletteCollapsed = true;
|
|
196
|
+
this._wasNarrow = narrow;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
connectedCallback() {
|
|
200
|
+
super.connectedCallback();
|
|
201
|
+
this._registerSlottedTemplates();
|
|
202
|
+
this._resizeObserver.observe(this);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
disconnectedCallback() {
|
|
206
|
+
this._resizeObserver.disconnect();
|
|
207
|
+
this._stopAutoSave();
|
|
208
|
+
if (this._justSavedTimer !== null) {
|
|
209
|
+
clearTimeout(this._justSavedTimer);
|
|
210
|
+
this._justSavedTimer = null;
|
|
211
|
+
}
|
|
212
|
+
super.disconnectedCallback();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Auto-save (mirrors flow-builder's) --------------------------------------
|
|
216
|
+
|
|
217
|
+
private _autoSaveTimer: number | null = null;
|
|
218
|
+
private _statusTimer: number | null = null;
|
|
219
|
+
private _justSavedTimer: number | null = null;
|
|
220
|
+
/** Guards the restore prompt from re-triggering on restoreAutoSave's own state install. */
|
|
221
|
+
private _restoring = false;
|
|
222
|
+
|
|
223
|
+
/** Epoch of the newest auto-save (also picked up from storage on start). */
|
|
224
|
+
@state() private _lastSavedAt: number | null = null;
|
|
225
|
+
/** Briefly true right after a save — flashes "Auto-saved" in the status pill. */
|
|
226
|
+
@state() private _justSaved = false;
|
|
227
|
+
/** Re-render clock for the "last saved Xm ago" label. */
|
|
228
|
+
@state() private _statusNow = Date.now();
|
|
229
|
+
/** A fresh auto-save differing from the loaded page — offer to restore it. */
|
|
230
|
+
@state() private _restorePrompt: {savedAt: number} | null = null;
|
|
231
|
+
|
|
232
|
+
/** localStorage key for this builder's auto-saves — its id, else its heading. */
|
|
233
|
+
private get _autoSaveKey(): string {
|
|
234
|
+
return `zn-page-builder:${this.id || this.heading || 'page'}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The "Auto-saved" flash timeout is deliberately not cleared here — it always
|
|
238
|
+
// runs out 2.5s after the last save, even if the schedule changes meanwhile.
|
|
239
|
+
private _stopAutoSave() {
|
|
240
|
+
if (this._autoSaveTimer !== null) {
|
|
241
|
+
clearInterval(this._autoSaveTimer);
|
|
242
|
+
this._autoSaveTimer = null;
|
|
243
|
+
}
|
|
244
|
+
if (this._statusTimer !== null) {
|
|
245
|
+
clearInterval(this._statusTimer);
|
|
246
|
+
this._statusTimer = null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private _restartAutoSave() {
|
|
251
|
+
this._stopAutoSave();
|
|
252
|
+
if (this.autoSave === null) return;
|
|
253
|
+
// Housekeeping: drop an expired auto-save, and carry its timestamp into the
|
|
254
|
+
// status pill when one survives — "last saved" outlives a reload.
|
|
255
|
+
const saved = this._readAutoSave();
|
|
256
|
+
if (saved) this._lastSavedAt = saved.savedAt;
|
|
257
|
+
this._autoSaveTimer = window.setInterval(this._autoSaveTick, this.autoSave * 60_000);
|
|
258
|
+
this._statusTimer = window.setInterval(() => (this._statusNow = Date.now()), 30_000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** An empty page is never saved — it would clobber a stored page with nothing. */
|
|
262
|
+
private _autoSaveTick = () => {
|
|
263
|
+
if (!this._state.sections.length || this._restorePrompt) return;
|
|
264
|
+
try {
|
|
265
|
+
localStorage.setItem(this._autoSaveKey, JSON.stringify({savedAt: Date.now(), state: this._state}));
|
|
266
|
+
this._lastSavedAt = Date.now();
|
|
267
|
+
this._justSaved = true;
|
|
268
|
+
if (this._justSavedTimer !== null) clearTimeout(this._justSavedTimer);
|
|
269
|
+
this._justSavedTimer = window.setTimeout(() => (this._justSaved = false), 2500);
|
|
270
|
+
} catch {
|
|
271
|
+
/* storage unavailable / full */
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** The stored auto-save, purging it when past its TTL (or unreadable). */
|
|
276
|
+
private _readAutoSave(): {savedAt: number; state: PageState} | null {
|
|
277
|
+
try {
|
|
278
|
+
const raw = localStorage.getItem(this._autoSaveKey);
|
|
279
|
+
if (!raw) return null;
|
|
280
|
+
const saved = JSON.parse(raw) as {savedAt: number; state: PageState};
|
|
281
|
+
if (!saved.state || Date.now() - saved.savedAt > AUTO_SAVE_TTL_MS) {
|
|
282
|
+
localStorage.removeItem(this._autoSaveKey);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return saved;
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Load the auto-saved page, if one exists within the 1-day TTL. */
|
|
292
|
+
restoreAutoSave = (): boolean => {
|
|
293
|
+
const saved = this._readAutoSave();
|
|
294
|
+
if (!saved) return false;
|
|
295
|
+
this._restoring = true;
|
|
296
|
+
try {
|
|
297
|
+
this._applyExternalState(saved.state);
|
|
298
|
+
} finally {
|
|
299
|
+
this._restoring = false;
|
|
300
|
+
}
|
|
301
|
+
this._restorePrompt = null;
|
|
302
|
+
return true;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A page was just loaded — when a fresh auto-save differs from it, ask the
|
|
307
|
+
* user whether to pick up their draft instead.
|
|
308
|
+
*/
|
|
309
|
+
private _offerRestoreIfNewer() {
|
|
310
|
+
if (this.autoSave === null || this._restoring) return;
|
|
311
|
+
const saved = this._readAutoSave();
|
|
312
|
+
this._restorePrompt = saved && JSON.stringify(saved.state) !== JSON.stringify(this._state)
|
|
313
|
+
? {savedAt: saved.savedAt}
|
|
314
|
+
: null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
protected willUpdate(changed: PropertyValues) {
|
|
318
|
+
super.willUpdate(changed);
|
|
319
|
+
if (changed.has('_selectedId')) this._buildInspectorForm();
|
|
320
|
+
if (changed.has('autoSave')) this._restartAutoSave();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Slotted templates define section types --------------------------------
|
|
324
|
+
|
|
325
|
+
private _typeFromTemplate(el: HTMLTemplateElement): PageSectionType | null {
|
|
326
|
+
const type = el.getAttribute('type');
|
|
327
|
+
if (!type) return null;
|
|
328
|
+
const slots = parseInt(el.getAttribute('slots') ?? '', 10);
|
|
329
|
+
return {
|
|
330
|
+
type,
|
|
331
|
+
label: el.getAttribute('label') ?? type,
|
|
332
|
+
icon: el.getAttribute('icon') ?? undefined,
|
|
333
|
+
iconLibrary: el.getAttribute('icon-library') ?? undefined,
|
|
334
|
+
color: el.getAttribute('color') ?? undefined,
|
|
335
|
+
category: el.getAttribute('category') ?? undefined,
|
|
336
|
+
description: el.getAttribute('description') ?? undefined,
|
|
337
|
+
configTemplate: el,
|
|
338
|
+
slots: slots > 0 ? slots : undefined,
|
|
339
|
+
accepts: el.getAttribute('accepts')?.split(',').map(s => s.trim()).filter(Boolean),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private _registerSlottedTemplates = () => {
|
|
344
|
+
let added = false;
|
|
345
|
+
this.querySelectorAll<HTMLTemplateElement>('template[slot="config"][type]').forEach(el => {
|
|
346
|
+
const type = this._typeFromTemplate(el);
|
|
347
|
+
if (type && !this.registry.has(type.type)) {
|
|
348
|
+
this.registry.register(type);
|
|
349
|
+
added = true;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
if (added) this.requestUpdate();
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** Normalises and installs an externally provided state; resets selection. */
|
|
356
|
+
private _applyExternalState(next: PageState) {
|
|
357
|
+
const seen = new Set<string>();
|
|
358
|
+
let clippedChildren = false;
|
|
359
|
+
// Nesting is one level deep by design — grandchildren in malformed input are
|
|
360
|
+
// dropped, which also bounds the recursion. Duplicate ids get regenerated so
|
|
361
|
+
// selection/patching can never target two sections at once, and children are
|
|
362
|
+
// capped (slots are single-digit by design; MAX_SECTIONS alone would still
|
|
363
|
+
// admit one section with a huge children array).
|
|
364
|
+
const normalise = (s: PageSection, depth: number): PageSection => {
|
|
365
|
+
const id = !s.id || seen.has(s.id) ? generateSectionId() : s.id;
|
|
366
|
+
seen.add(id);
|
|
367
|
+
if (depth === 0 && (s.children?.length ?? 0) > MAX_CHILDREN) clippedChildren = true;
|
|
368
|
+
return {
|
|
369
|
+
id,
|
|
370
|
+
type: s.type,
|
|
371
|
+
label: s.label,
|
|
372
|
+
data: structuredClone(s.data ?? {}),
|
|
373
|
+
...(s.children && depth === 0
|
|
374
|
+
? {children: s.children.slice(0, MAX_CHILDREN).map(c => (c && typeof c.type === 'string' ? normalise(c, depth + 1) : null))}
|
|
375
|
+
: {}),
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
const incoming = (next?.sections ?? []).filter(s => s && typeof s.type === 'string');
|
|
379
|
+
if (incoming.length > MAX_SECTIONS) {
|
|
380
|
+
console.warn(`<zn-page-builder> config has ${incoming.length} sections; keeping the first ${MAX_SECTIONS}`);
|
|
381
|
+
}
|
|
382
|
+
const sections = incoming.slice(0, MAX_SECTIONS).map(s => normalise(s, 0));
|
|
383
|
+
if (clippedChildren) {
|
|
384
|
+
console.warn(`<zn-page-builder> some sections had more than ${MAX_CHILDREN} children; extras were dropped`);
|
|
385
|
+
}
|
|
386
|
+
this._history = [];
|
|
387
|
+
this._redoStack = [];
|
|
388
|
+
this._state = {sections};
|
|
389
|
+
this._selectedId = null;
|
|
390
|
+
this._pickerIndex = null;
|
|
391
|
+
this._offerRestoreIfNewer();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Finds a section by id, searching top-level sections and slotted children. */
|
|
395
|
+
private _findSection(id: string | null): PageSection | undefined {
|
|
396
|
+
if (!id) return undefined;
|
|
397
|
+
for (const s of this._state.sections) {
|
|
398
|
+
if (s.id === id) return s;
|
|
399
|
+
const child = s.children?.find(c => c?.id === id);
|
|
400
|
+
if (child) return child;
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** New sections array with the section patched wherever it lives (top level or slot). */
|
|
406
|
+
private _patchSection(id: string, patch: (s: PageSection) => PageSection): PageSection[] {
|
|
407
|
+
return this._state.sections.map(s => {
|
|
408
|
+
if (s.id === id) return patch(s);
|
|
409
|
+
if (s.children?.some(c => c?.id === id)) {
|
|
410
|
+
return {...s, children: s.children.map(c => (c?.id === id ? patch(c) : c))};
|
|
411
|
+
}
|
|
412
|
+
return s;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Detaches a section wherever it lives: removed from the top level, or its slot nulled. */
|
|
417
|
+
private _extract(id: string): [PageSection | undefined, PageSection[]] {
|
|
418
|
+
let removed: PageSection | undefined;
|
|
419
|
+
const sections: PageSection[] = [];
|
|
420
|
+
for (const s of this._state.sections) {
|
|
421
|
+
if (s.id === id) {
|
|
422
|
+
removed = s;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
|
|
426
|
+
if (slot !== -1) {
|
|
427
|
+
removed = s.children![slot] ?? undefined;
|
|
428
|
+
sections.push({...s, children: s.children!.map((c, i) => (i === slot ? null : c))});
|
|
429
|
+
} else {
|
|
430
|
+
sections.push(s);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return [removed, sections];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Installs a new state from a user edit and notifies listeners. */
|
|
437
|
+
private _commit(next: PageState) {
|
|
438
|
+
this._state = next;
|
|
439
|
+
this.emit('zn-page-change', {detail: {state: this.state}});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private _selectedSection(): PageSection | undefined {
|
|
443
|
+
return this._findSection(this._selectedId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private _select(id: string | null) {
|
|
447
|
+
if (this._selectedId === id) return;
|
|
448
|
+
this._selectedId = id;
|
|
449
|
+
this._pickerIndex = null;
|
|
450
|
+
this._slotPicker = null;
|
|
451
|
+
this.emit('zn-page-selection-change', {detail: {sectionId: id}});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- History ----------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
private _pushHistory() {
|
|
457
|
+
this._history.push(structuredClone(this._state));
|
|
458
|
+
if (this._history.length > HISTORY_LIMIT) this._history.shift();
|
|
459
|
+
this._redoStack = [];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Bound fields (not methods) so hosts can pass them straight to event
|
|
463
|
+
// listeners — `button.addEventListener('click', builder.undo)`.
|
|
464
|
+
undo = () => {
|
|
465
|
+
const prev = this._history.pop();
|
|
466
|
+
if (!prev) return;
|
|
467
|
+
this._redoStack.push(structuredClone(this._state));
|
|
468
|
+
this._select(null);
|
|
469
|
+
this._commit(prev);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
redo = () => {
|
|
473
|
+
const next = this._redoStack.pop();
|
|
474
|
+
if (!next) return;
|
|
475
|
+
this._history.push(structuredClone(this._state));
|
|
476
|
+
this._select(null);
|
|
477
|
+
this._commit(next);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// --- Section mutations ------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/** Adds a section of a registered type at `index` (default: end). Returns null for unknown types. */
|
|
483
|
+
addSection(type: string, index?: number): PageSection | null {
|
|
484
|
+
if (!this.registry.has(type)) return null;
|
|
485
|
+
this._pushHistory();
|
|
486
|
+
const section: PageSection = {id: generateSectionId(), type, data: {}};
|
|
487
|
+
const sections = [...this._state.sections];
|
|
488
|
+
sections.splice(index ?? sections.length, 0, section);
|
|
489
|
+
this._commit({sections});
|
|
490
|
+
this._select(section.id);
|
|
491
|
+
return section;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Adds a new section of a registered type into a container's slot. Returns null if not allowed. */
|
|
495
|
+
addSectionToSlot(type: string, containerId: string, slotIndex: number): PageSection | null {
|
|
496
|
+
const sectionType = this.registry.get(type);
|
|
497
|
+
const container = this._findSection(containerId);
|
|
498
|
+
const containerType = container ? this.registry.get(container.type) : undefined;
|
|
499
|
+
if (!sectionType || sectionType.slots || !container || !containerType?.slots) return null;
|
|
500
|
+
if (slotIndex < 0 || slotIndex >= containerType.slots) return null;
|
|
501
|
+
if (containerType.accepts && !containerType.accepts.includes(type)) return null;
|
|
502
|
+
const children = sectionChildren(container, containerType);
|
|
503
|
+
if (children[slotIndex]) return null;
|
|
504
|
+
const section: PageSection = {id: generateSectionId(), type, data: {}};
|
|
505
|
+
children[slotIndex] = section;
|
|
506
|
+
this._pushHistory();
|
|
507
|
+
this._commit({sections: this._patchSection(containerId, s => ({...s, children}))});
|
|
508
|
+
this._select(section.id);
|
|
509
|
+
return section;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private _removeSection(id: string) {
|
|
513
|
+
const [removed, sections] = this._extract(id);
|
|
514
|
+
if (!removed) return;
|
|
515
|
+
this._pushHistory();
|
|
516
|
+
// Clear selection for the removed section AND anything inside it.
|
|
517
|
+
if (this._selectedId === id || removed.children?.some(c => c?.id === this._selectedId)) {
|
|
518
|
+
this._select(null);
|
|
519
|
+
}
|
|
520
|
+
this._commit({sections});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private _duplicateSection(id: string) {
|
|
524
|
+
const index = this._state.sections.findIndex(s => s.id === id);
|
|
525
|
+
if (index !== -1) {
|
|
526
|
+
this._pushHistory();
|
|
527
|
+
const copy = structuredClone(this._state.sections[index]);
|
|
528
|
+
copy.id = generateSectionId();
|
|
529
|
+
copy.children = copy.children?.map(c => (c ? {...c, id: generateSectionId()} : null));
|
|
530
|
+
const sections = [...this._state.sections];
|
|
531
|
+
sections.splice(index + 1, 0, copy);
|
|
532
|
+
this._commit({sections});
|
|
533
|
+
this._select(copy.id);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// A slotted child duplicates into its container's next empty slot, if any.
|
|
537
|
+
for (const s of this._state.sections) {
|
|
538
|
+
const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
|
|
539
|
+
if (slot === -1) continue;
|
|
540
|
+
const type = this.registry.get(s.type);
|
|
541
|
+
if (!type?.slots) return;
|
|
542
|
+
const children = sectionChildren(s, type);
|
|
543
|
+
const empty = children.findIndex(c => !c);
|
|
544
|
+
if (empty === -1) return;
|
|
545
|
+
const copy = structuredClone(children[slot]!);
|
|
546
|
+
copy.id = generateSectionId();
|
|
547
|
+
children[empty] = copy;
|
|
548
|
+
this._pushHistory();
|
|
549
|
+
this._commit({sections: this._patchSection(s.id, x => ({...x, children}))});
|
|
550
|
+
this._select(copy.id);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Moves a section (top-level or slotted) to a top-level position. */
|
|
556
|
+
private _moveSection(id: string, index: number) {
|
|
557
|
+
const from = this._state.sections.findIndex(s => s.id === id);
|
|
558
|
+
if (from !== -1) {
|
|
559
|
+
const to = index > from ? index - 1 : index;
|
|
560
|
+
if (to === from) return;
|
|
561
|
+
this._pushHistory();
|
|
562
|
+
const sections = [...this._state.sections];
|
|
563
|
+
const [moved] = sections.splice(from, 1);
|
|
564
|
+
sections.splice(to, 0, moved);
|
|
565
|
+
this._commit({sections});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const [moved, sections] = this._extract(id);
|
|
569
|
+
if (!moved) return;
|
|
570
|
+
this._pushHistory();
|
|
571
|
+
sections.splice(index, 0, moved);
|
|
572
|
+
this._commit({sections});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Moves a section into a container's slot. Dropping onto an occupied slot swaps
|
|
577
|
+
* the two children (slot-to-slot reordering); top-level sections and containers
|
|
578
|
+
* only enter empty slots / never enter slots respectively.
|
|
579
|
+
*/
|
|
580
|
+
private _moveToSlot(id: string, containerId: string, slotIndex: number) {
|
|
581
|
+
const moved = this._findSection(id);
|
|
582
|
+
const container = this._findSection(containerId);
|
|
583
|
+
const containerType = container ? this.registry.get(container.type) : undefined;
|
|
584
|
+
if (!moved || !container || !containerType?.slots || id === containerId) return;
|
|
585
|
+
if (this.registry.get(moved.type)?.slots) return; // no containers inside slots
|
|
586
|
+
if (containerType.accepts && !containerType.accepts.includes(moved.type)) return;
|
|
587
|
+
if (slotIndex < 0 || slotIndex >= containerType.slots) return;
|
|
588
|
+
|
|
589
|
+
const target = container.children?.[slotIndex] ?? null;
|
|
590
|
+
if (target?.id === id) return;
|
|
591
|
+
const fromTop = this._state.sections.some(s => s.id === id);
|
|
592
|
+
if (target && fromTop) return; // top-level sections only drop on empty slots
|
|
593
|
+
|
|
594
|
+
this._pushHistory();
|
|
595
|
+
const sections = structuredClone(this._state.sections);
|
|
596
|
+
const containerRef = sections.find(s => s.id === containerId)!;
|
|
597
|
+
containerRef.children = Array.from({length: containerType.slots}, (_, i) => containerRef.children?.[i] ?? null);
|
|
598
|
+
const movedCopy = structuredClone(moved);
|
|
599
|
+
|
|
600
|
+
if (fromTop) {
|
|
601
|
+
sections.splice(sections.findIndex(s => s.id === id), 1);
|
|
602
|
+
} else {
|
|
603
|
+
// Swap: the occupant (or null) takes the source slot.
|
|
604
|
+
for (const s of sections) {
|
|
605
|
+
const slot = s.children?.findIndex(c => c?.id === id) ?? -1;
|
|
606
|
+
if (slot !== -1) {
|
|
607
|
+
s.children![slot] = target ? structuredClone(target) : null;
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
containerRef.children[slotIndex] = movedCopy;
|
|
613
|
+
this._commit({sections});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// --- Canvas drag & drop -----------------------------------------------------
|
|
617
|
+
|
|
618
|
+
private _onCardDragStart(e: DragEvent, id: string) {
|
|
619
|
+
if (!e.dataTransfer) return;
|
|
620
|
+
e.stopPropagation(); // a child card's drag must not also start its container's
|
|
621
|
+
e.dataTransfer.setData(PAGE_SECTION_MIME, id);
|
|
622
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Whether a drag carries one of the builder's own payloads. */
|
|
626
|
+
private _isPageDrag(e: DragEvent): boolean {
|
|
627
|
+
const types = e.dataTransfer ? Array.from(e.dataTransfer.types) : [];
|
|
628
|
+
return types.includes(PAGE_TYPE_MIME) || types.includes(PAGE_SECTION_MIME);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private _onSlotDragOver(e: DragEvent, containerId: string, index: number) {
|
|
632
|
+
if (!this._isPageDrag(e)) return;
|
|
633
|
+
e.preventDefault();
|
|
634
|
+
e.stopPropagation();
|
|
635
|
+
this._dragOverIndex = null;
|
|
636
|
+
this._slotDragOver = {containerId, index};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private _onSlotDrop(e: DragEvent, containerId: string, index: number) {
|
|
640
|
+
e.preventDefault();
|
|
641
|
+
e.stopPropagation();
|
|
642
|
+
this._slotDragOver = null;
|
|
643
|
+
const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
|
|
644
|
+
const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
|
|
645
|
+
if (typeKey) this.addSectionToSlot(typeKey, containerId, index);
|
|
646
|
+
else if (sectionId) this._moveToSlot(sectionId, containerId, index);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private _onZoneDragOver(e: DragEvent, index: number) {
|
|
650
|
+
if (!this._isPageDrag(e)) return;
|
|
651
|
+
e.preventDefault();
|
|
652
|
+
e.stopPropagation(); // claim this index over the canvas-level fallback
|
|
653
|
+
this._dragOverIndex = index;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private _onZoneDrop(e: DragEvent, index: number) {
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
e.stopPropagation();
|
|
659
|
+
this._dragOverIndex = null;
|
|
660
|
+
const typeKey = e.dataTransfer?.getData(PAGE_TYPE_MIME);
|
|
661
|
+
const sectionId = e.dataTransfer?.getData(PAGE_SECTION_MIME);
|
|
662
|
+
if (typeKey) this.addSection(typeKey, index);
|
|
663
|
+
else if (sectionId) this._moveSection(sectionId, index);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Canvas-level fallback: the strips between cards are thin, so accept drags
|
|
667
|
+
// anywhere on the canvas and target the end of the page.
|
|
668
|
+
private _onCanvasDragOver = (e: DragEvent) => {
|
|
669
|
+
if (!this._isPageDrag(e)) return;
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
this._dragOverIndex = this._state.sections.length;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
private _onCanvasDrop = (e: DragEvent) => {
|
|
675
|
+
this._onZoneDrop(e, this._state.sections.length);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
private _onCardKeydown(e: KeyboardEvent, id: string) {
|
|
679
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
680
|
+
e.preventDefault();
|
|
681
|
+
this._removeSection(id);
|
|
682
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
683
|
+
e.preventDefault();
|
|
684
|
+
this._select(id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private _renderPalette() {
|
|
689
|
+
const query = this._search.trim().toLowerCase();
|
|
690
|
+
const categories = new Map<string, PageSectionType[]>();
|
|
691
|
+
for (const [category, types] of this.registry.categories()) {
|
|
692
|
+
const matches = query ? types.filter(t=>t.label.toLowerCase().includes(query)) : types;
|
|
693
|
+
if (matches.length) categories.set(category, matches);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return html`
|
|
697
|
+
<aside part="palette" class="palette">
|
|
698
|
+
<div class="palette__title">
|
|
699
|
+
<div class="palette__text">
|
|
700
|
+
<div class="palette__heading">${this.heading || 'Page Builder'}</div>
|
|
701
|
+
${this.subheading ? html`
|
|
702
|
+
<div class="palette__subheading">${this.subheading}</div>` : ''}
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
<zn-input
|
|
706
|
+
class="palette__search"
|
|
707
|
+
label="Search sections"
|
|
708
|
+
placeholder="Search sections"
|
|
709
|
+
clearable
|
|
710
|
+
.value="${this._search}"
|
|
711
|
+
@zn-input="${(e: Event)=>(this._search = String((e.target as ZnInput).value ?? ''))}"></zn-input>
|
|
712
|
+
<div class="palette__scroll">
|
|
713
|
+
${[...categories.entries()].map(([category, types]) => category
|
|
714
|
+
? html`
|
|
715
|
+
<zn-collapsible class="palette__category" caption="${category}" default="open">
|
|
716
|
+
${types.map(t => this._renderPaletteItem(t))}
|
|
717
|
+
</zn-collapsible>`
|
|
718
|
+
: html`
|
|
719
|
+
<div class="palette__uncategorized">${types.map(t => this._renderPaletteItem(t))}</div>`)}
|
|
720
|
+
</div>
|
|
721
|
+
</aside>`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private _renderPaletteItem(type: PageSectionType) {
|
|
725
|
+
return html`
|
|
726
|
+
<zn-page-palette-item
|
|
727
|
+
type="${type.type}"
|
|
728
|
+
label="${type.label}"
|
|
729
|
+
description="${ifDefined(type.description)}"
|
|
730
|
+
icon="${ifDefined(type.icon)}"
|
|
731
|
+
icon-library="${ifDefined(type.iconLibrary)}"
|
|
732
|
+
color="${ifDefined(type.color)}"></zn-page-palette-item>`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Bottom-left of the canvas: flashes as each auto-save lands, otherwise
|
|
736
|
+
// shows how long ago the last one happened.
|
|
737
|
+
private _renderAutoSaveStatus() {
|
|
738
|
+
if (this.autoSave === null || (!this._justSaved && this._lastSavedAt === null)) return '';
|
|
739
|
+
const label = this._justSaved
|
|
740
|
+
? 'Auto-saved'
|
|
741
|
+
: `Last saved ${timeAgo(Math.max(0, this._statusNow - (this._lastSavedAt ?? 0)))}`;
|
|
742
|
+
return html`
|
|
743
|
+
<div class="save-status ${this._justSaved ? 'save-status--saved' : ''}">
|
|
744
|
+
<zn-icon src="${this._justSaved ? 'check@lu' : 'history@lu'}" size="14"></zn-icon>
|
|
745
|
+
<span>${label}</span>
|
|
746
|
+
</div>
|
|
747
|
+
`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Offered when a loaded page differs from a fresh auto-saved draft.
|
|
751
|
+
private _renderRestorePrompt() {
|
|
752
|
+
if (!this._restorePrompt) return '';
|
|
753
|
+
return html`
|
|
754
|
+
<div class="restore-banner" @click="${(e: Event) => e.stopPropagation()}">
|
|
755
|
+
<zn-icon src="history@lu" size="16"></zn-icon>
|
|
756
|
+
<span>An auto-saved draft from ${timeAgo(Date.now() - this._restorePrompt.savedAt)} differs from this page.</span>
|
|
757
|
+
<button class="restore-banner__restore" @click="${() => this.restoreAutoSave()}">Restore</button>
|
|
758
|
+
<button class="restore-banner__dismiss" @click="${() => (this._restorePrompt = null)}">Dismiss</button>
|
|
759
|
+
</div>
|
|
760
|
+
`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private _renderCanvas() {
|
|
764
|
+
const sections = this._state.sections;
|
|
765
|
+
return html`
|
|
766
|
+
<div class="canvas-cell">
|
|
767
|
+
<button
|
|
768
|
+
type="button"
|
|
769
|
+
class="panel-toggle panel-toggle--left ${this.paletteCollapsed ? 'panel-toggle--tucked' : ''}"
|
|
770
|
+
title="${this.paletteCollapsed ? 'Show sections palette' : 'Hide sections palette'}"
|
|
771
|
+
aria-label="${this.paletteCollapsed ? 'Show sections palette' : 'Hide sections palette'}"
|
|
772
|
+
@click="${(e: Event) => {
|
|
773
|
+
e.stopPropagation();
|
|
774
|
+
this.paletteCollapsed = !this.paletteCollapsed;
|
|
775
|
+
}}">
|
|
776
|
+
<zn-icon src="${this.paletteCollapsed ? 'chevron-right@lu' : 'chevron-left@lu'}" size="16"></zn-icon>
|
|
777
|
+
</button>
|
|
778
|
+
${this._selectedId ? html`
|
|
779
|
+
<button
|
|
780
|
+
type="button"
|
|
781
|
+
class="panel-toggle panel-toggle--right ${this.inspectorCollapsed ? 'panel-toggle--tucked' : ''}"
|
|
782
|
+
title="${this.inspectorCollapsed ? 'Show section settings' : 'Hide section settings'}"
|
|
783
|
+
aria-label="${this.inspectorCollapsed ? 'Show section settings' : 'Hide section settings'}"
|
|
784
|
+
@click="${(e: Event) => {
|
|
785
|
+
e.stopPropagation();
|
|
786
|
+
this.inspectorCollapsed = !this.inspectorCollapsed;
|
|
787
|
+
}}">
|
|
788
|
+
<zn-icon src="${this.inspectorCollapsed ? 'chevron-left@lu' : 'chevron-right@lu'}" size="16"></zn-icon>
|
|
789
|
+
</button>` : ''}
|
|
790
|
+
${this._renderAutoSaveStatus()}
|
|
791
|
+
${this._renderRestorePrompt()}
|
|
792
|
+
<main
|
|
793
|
+
part="canvas"
|
|
794
|
+
class="canvas"
|
|
795
|
+
@click="${() => this._select(null)}"
|
|
796
|
+
@dragover="${this._onCanvasDragOver}"
|
|
797
|
+
@drop="${this._onCanvasDrop}">
|
|
798
|
+
${sections.length === 0 ? html`
|
|
799
|
+
<div class="canvas__empty" ?hidden="${this._dragOverIndex !== null}">
|
|
800
|
+
Drag sections here to build your page
|
|
801
|
+
</div>` : ''}
|
|
802
|
+
${sections.map((section, i) => html`
|
|
803
|
+
${this._renderDropZone(i)}
|
|
804
|
+
${this._renderCard(section, i)}
|
|
805
|
+
`)}
|
|
806
|
+
${this._renderDropZone(sections.length)}
|
|
807
|
+
</main>
|
|
808
|
+
</div>`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The one card template both the page list and slot cells render. */
|
|
812
|
+
private _renderSectionCard(
|
|
813
|
+
section: PageSection,
|
|
814
|
+
drop: {over: (e: DragEvent) => void; drop: (e: DragEvent) => void},
|
|
815
|
+
extraClass = ''
|
|
816
|
+
) {
|
|
817
|
+
const type = this.registry.get(section.type);
|
|
818
|
+
return html`
|
|
819
|
+
<zn-page-section-card
|
|
820
|
+
class="${extraClass}"
|
|
821
|
+
draggable="true"
|
|
822
|
+
tabindex="0"
|
|
823
|
+
label="${section.label ?? type?.label ?? section.type}"
|
|
824
|
+
summary="${type ? sectionSummary(section, type) : `Unknown type "${section.type}"`}"
|
|
825
|
+
icon="${ifDefined(type?.icon)}"
|
|
826
|
+
icon-library="${ifDefined(type?.iconLibrary)}"
|
|
827
|
+
color="${ifDefined(type?.color)}"
|
|
828
|
+
?selected="${this._selectedId === section.id}"
|
|
829
|
+
?unknown="${!type}"
|
|
830
|
+
@click="${(e: Event) => {
|
|
831
|
+
e.stopPropagation();
|
|
832
|
+
this._select(section.id);
|
|
833
|
+
}}"
|
|
834
|
+
@keydown="${(e: KeyboardEvent) => this._onCardKeydown(e, section.id)}"
|
|
835
|
+
@dragstart="${(e: DragEvent) => this._onCardDragStart(e, section.id)}"
|
|
836
|
+
@dragover="${drop.over}"
|
|
837
|
+
@drop="${drop.drop}"
|
|
838
|
+
@page-card-duplicate="${() => this._duplicateSection(section.id)}"
|
|
839
|
+
@page-card-remove="${() => this._removeSection(section.id)}"></zn-page-section-card>`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private _renderCard(section: PageSection, index: number) {
|
|
843
|
+
const type = this.registry.get(section.type);
|
|
844
|
+
const card = this._renderSectionCard(section, {
|
|
845
|
+
over: e => this._onZoneDragOver(e, index + 1),
|
|
846
|
+
drop: e => this._onZoneDrop(e, index + 1),
|
|
847
|
+
});
|
|
848
|
+
if (!type?.slots) return card;
|
|
849
|
+
return html`
|
|
850
|
+
<div class="container">
|
|
851
|
+
${card}
|
|
852
|
+
<div class="slots">
|
|
853
|
+
${sectionChildren(section, type).map((child, i) => this._renderSlot(section, child, i))}
|
|
854
|
+
</div>
|
|
855
|
+
</div>`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private _renderSlot(container: PageSection, child: PageSection | null, index: number) {
|
|
859
|
+
if (child) {
|
|
860
|
+
return this._renderSectionCard(child, {
|
|
861
|
+
over: e => this._onSlotDragOver(e, container.id, index),
|
|
862
|
+
drop: e => this._onSlotDrop(e, container.id, index),
|
|
863
|
+
}, 'slot__card');
|
|
864
|
+
}
|
|
865
|
+
const active = this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index;
|
|
866
|
+
const pickerOpen = this._slotPicker?.containerId === container.id && this._slotPicker.index === index;
|
|
867
|
+
return html`
|
|
868
|
+
<div
|
|
869
|
+
class="slot slot--empty ${active ? 'slot--active' : ''}"
|
|
870
|
+
@dragover="${(e: DragEvent) => this._onSlotDragOver(e, container.id, index)}"
|
|
871
|
+
@dragleave="${() => {
|
|
872
|
+
if (this._slotDragOver?.containerId === container.id && this._slotDragOver.index === index) {
|
|
873
|
+
this._slotDragOver = null;
|
|
874
|
+
}
|
|
875
|
+
}}"
|
|
876
|
+
@drop="${(e: DragEvent) => this._onSlotDrop(e, container.id, index)}"
|
|
877
|
+
@click="${(e: Event) => {
|
|
878
|
+
e.stopPropagation();
|
|
879
|
+
this._slotPicker = pickerOpen ? null : {containerId: container.id, index};
|
|
880
|
+
}}">
|
|
881
|
+
<zn-icon src="add" size="16"></zn-icon>
|
|
882
|
+
${pickerOpen
|
|
883
|
+
? this._renderTypePicker(this._slotTypes(container), t => this.addSectionToSlot(t, container.id, index))
|
|
884
|
+
: ''}
|
|
885
|
+
</div>`;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Types allowed in a container's slots: non-containers, filtered by its accepts list. */
|
|
889
|
+
private _slotTypes(container: PageSection): PageSectionType[] {
|
|
890
|
+
const containerType = this.registry.get(container.type);
|
|
891
|
+
return this.registry.all().filter(t =>
|
|
892
|
+
!t.slots && (!containerType?.accepts || containerType.accepts.includes(t.type)));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** The one type-picker template both drop zones and slot cells render. */
|
|
896
|
+
private _renderTypePicker(types: PageSectionType[], pick: (type: string) => void) {
|
|
897
|
+
return html`
|
|
898
|
+
<div class="picker" @click="${(e: Event) => e.stopPropagation()}">
|
|
899
|
+
${types.map(type => html`
|
|
900
|
+
<button
|
|
901
|
+
type="button"
|
|
902
|
+
class="picker__item"
|
|
903
|
+
@click="${() => {
|
|
904
|
+
this._pickerIndex = null;
|
|
905
|
+
this._slotPicker = null;
|
|
906
|
+
pick(type.type);
|
|
907
|
+
}}">
|
|
908
|
+
<zn-icon src="${type.icon ?? 'widgets'}" library="${ifDefined(type.iconLibrary)}" size="14"></zn-icon>
|
|
909
|
+
${type.label}
|
|
910
|
+
</button>`)}
|
|
911
|
+
</div>`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private _renderDropZone(index: number) {
|
|
915
|
+
return html`
|
|
916
|
+
<div
|
|
917
|
+
class="drop ${this._dragOverIndex === index ? 'drop--active' : ''} ${this._state.sections.length === 0 ? 'drop--solo' : ''}"
|
|
918
|
+
@dragover="${(e: DragEvent) => this._onZoneDragOver(e, index)}"
|
|
919
|
+
@dragleave="${() => {
|
|
920
|
+
if (this._dragOverIndex === index) this._dragOverIndex = null;
|
|
921
|
+
}}"
|
|
922
|
+
@drop="${(e: DragEvent) => this._onZoneDrop(e, index)}">
|
|
923
|
+
<button
|
|
924
|
+
type="button"
|
|
925
|
+
class="drop__add"
|
|
926
|
+
title="Add section"
|
|
927
|
+
@click="${(e: Event) => {
|
|
928
|
+
e.stopPropagation();
|
|
929
|
+
this._pickerIndex = this._pickerIndex === index ? null : index;
|
|
930
|
+
}}">
|
|
931
|
+
<zn-icon src="add" size="14"></zn-icon>
|
|
932
|
+
</button>
|
|
933
|
+
${this._pickerIndex === index
|
|
934
|
+
? this._renderTypePicker(this.registry.all(), t => this.addSection(t, index))
|
|
935
|
+
: ''}
|
|
936
|
+
</div>`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
// --- Inspector --------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Clones the selected type's config template into a live form and prefills each
|
|
944
|
+
* `[name]` control from the section's data. The element is rendered directly by
|
|
945
|
+
* Lit (`${this._form}`) so user-entered values survive unrelated re-renders.
|
|
946
|
+
*/
|
|
947
|
+
private _buildInspectorForm() {
|
|
948
|
+
this._form = null;
|
|
949
|
+
const section = this._selectedSection();
|
|
950
|
+
const type = section ? this.registry.get(section.type) : undefined;
|
|
951
|
+
if (!section || !type?.configTemplate || type.renderConfig) return;
|
|
952
|
+
|
|
953
|
+
const form = document.createElement('div');
|
|
954
|
+
form.className = 'inspector__form';
|
|
955
|
+
form.append(type.configTemplate.content.cloneNode(true));
|
|
956
|
+
form.querySelectorAll<HTMLElement>('[name]').forEach(control => {
|
|
957
|
+
const name = control.getAttribute('name')!;
|
|
958
|
+
const value = section.data[name];
|
|
959
|
+
if (this._isBooleanControl(control, value)) {
|
|
960
|
+
(control as HTMLInputElement).checked = Boolean(value);
|
|
961
|
+
} else if (Array.isArray(value)) {
|
|
962
|
+
// Multi-value controls (e.g. zn-select[multiple]) take the array as-is.
|
|
963
|
+
(control as unknown as {value: unknown}).value = value;
|
|
964
|
+
} else if (value !== undefined && value !== null) {
|
|
965
|
+
(control as HTMLInputElement).value = String(value);
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
this._form = form;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private _isBooleanControl(control: HTMLElement, value: unknown): boolean {
|
|
972
|
+
return typeof value === 'boolean'
|
|
973
|
+
|| control.matches('zn-toggle, zn-checkbox, input[type="checkbox"]');
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Handles change/input events bubbling from stamped form controls. */
|
|
977
|
+
private _onInspectorInput = (e: Event) => {
|
|
978
|
+
const section = this._selectedSection();
|
|
979
|
+
if (!section) return;
|
|
980
|
+
const control = e.target as HTMLElement & {value?: unknown; checked?: boolean};
|
|
981
|
+
const name = control.getAttribute?.('name');
|
|
982
|
+
if (!name) return;
|
|
983
|
+
let value: unknown = this._isBooleanControl(control, section.data[name])
|
|
984
|
+
? Boolean(control.checked)
|
|
985
|
+
: control.value;
|
|
986
|
+
if (control.getAttribute('type') === 'number' && value !== '' && value !== null && value !== undefined) {
|
|
987
|
+
value = Number(value);
|
|
988
|
+
}
|
|
989
|
+
if (typeof value === 'number' && Number.isNaN(value)) return;
|
|
990
|
+
if (section.data[name] === value) return;
|
|
991
|
+
this._updateSectionData(section.id, {[name]: value});
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
private _updateSectionData(id: string, patch: Record<string, unknown>) {
|
|
995
|
+
this._pushHistory();
|
|
996
|
+
this._commit({sections: this._patchSection(id, s => ({...s, data: {...s.data, ...patch}}))});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private _renameSection(id: string, label: string) {
|
|
1000
|
+
this._pushHistory();
|
|
1001
|
+
this._commit({sections: this._patchSection(id, s => ({...s, label: label || undefined}))});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private _renderInspector() {
|
|
1005
|
+
const section = this._selectedSection();
|
|
1006
|
+
if (!section) return html``;
|
|
1007
|
+
const type = this.registry.get(section.type);
|
|
1008
|
+
return html`
|
|
1009
|
+
<aside part="inspector" class="inspector">
|
|
1010
|
+
<zn-input
|
|
1011
|
+
class="inspector__rename"
|
|
1012
|
+
label="Section name"
|
|
1013
|
+
.value="${section.label ?? type?.label ?? ''}"
|
|
1014
|
+
@zn-change="${(e: Event) => this._renameSection(section.id, String((e.target as ZnInput).value ?? ''))}"></zn-input>
|
|
1015
|
+
<div
|
|
1016
|
+
class="inspector__body"
|
|
1017
|
+
@change="${this._onInspectorInput}"
|
|
1018
|
+
@zn-change="${this._onInspectorInput}"
|
|
1019
|
+
@input="${this._onInspectorInput}"
|
|
1020
|
+
@zn-input="${this._onInspectorInput}">
|
|
1021
|
+
${type?.renderConfig
|
|
1022
|
+
? type.renderConfig(section, data => this._updateSectionData(section.id, data))
|
|
1023
|
+
: this._form}
|
|
1024
|
+
</div>
|
|
1025
|
+
</aside>`;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
render() {
|
|
1029
|
+
const hasHeader = this._hasSlot.test('header-left') || this._hasSlot.test('header-right');
|
|
1030
|
+
return html`
|
|
1031
|
+
<div
|
|
1032
|
+
part="base"
|
|
1033
|
+
class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
|
|
1034
|
+
@dragend="${() => {
|
|
1035
|
+
this._dragOverIndex = null;
|
|
1036
|
+
this._slotDragOver = null;
|
|
1037
|
+
}}">
|
|
1038
|
+
<header part="header" class="header" ?hidden="${!hasHeader}">
|
|
1039
|
+
<div class="header__group">
|
|
1040
|
+
<slot name="header-left"></slot>
|
|
1041
|
+
</div>
|
|
1042
|
+
<div class="header__group">
|
|
1043
|
+
<slot name="header-right"></slot>
|
|
1044
|
+
</div>
|
|
1045
|
+
</header>
|
|
1046
|
+
${this._renderPalette()}
|
|
1047
|
+
${this._renderCanvas()}
|
|
1048
|
+
${this._renderInspector()}
|
|
1049
|
+
<slot name="config" class="declarations" @slotchange="${this._registerSlottedTemplates}"></slot>
|
|
1050
|
+
</div>
|
|
1051
|
+
`;
|
|
1052
|
+
}
|
|
1053
|
+
}
|