@kubex/zinc 1.1.94 → 1.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/custom-elements.json +995 -196
  2. package/dist/vscode.html-custom-data.json +74 -19
  3. package/dist/web-types.json +147 -37
  4. package/dist/zn.d.ts +335 -59
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +467 -355
  7. package/docs/pages/components/page-builder.md +121 -19
  8. package/docs/pages/components/slash-menu.md +132 -6
  9. package/docs/pages/components/textarea.md +16 -0
  10. package/package.json +1 -1
  11. package/scss/_root.scss +7 -1
  12. package/src/components/button/button.scss +5 -2
  13. package/src/components/inline-edit/inline-edit.component.ts +6 -1
  14. package/src/components/input/input.component.ts +12 -2
  15. package/src/components/page/page.scss +7 -2
  16. package/src/components/page-builder/modules/page-section-card/page-section-card.component.ts +16 -9
  17. package/src/components/page-builder/modules/page-section-card/page-section-card.scss +13 -0
  18. package/src/components/page-builder/modules/page-section-card/page-section-card.test.ts +9 -0
  19. package/src/components/page-builder/page-builder.component.ts +483 -225
  20. package/src/components/page-builder/page-builder.scss +64 -10
  21. package/src/components/page-builder/page-builder.test.ts +656 -110
  22. package/src/components/page-builder/page-tree.test.ts +483 -0
  23. package/src/components/page-builder/page-tree.ts +329 -0
  24. package/src/components/page-builder/page.types.ts +75 -7
  25. package/src/components/remarkd-editor/remarkd-editor.component.ts +198 -9
  26. package/src/components/remarkd-editor/remarkd-editor.scss +81 -0
  27. package/src/components/remarkd-editor/remarkd-editor.test.ts +179 -0
  28. package/src/components/settings-container/settings-container.scss +2 -1
  29. package/src/components/slash-item/slash-item.component.ts +1 -1
  30. package/src/components/slash-menu/slash-menu-items.ts +48 -0
  31. package/src/components/slash-menu/slash-menu.component.ts +134 -27
  32. package/src/components/slash-menu/slash-menu.scss +90 -12
  33. package/src/components/slash-menu/slash-menu.test.ts +107 -0
  34. package/src/components/textarea/textarea.component.ts +12 -2
  35. package/src/components/textarea/textarea.test.ts +2 -2
  36. package/src/components/translations/translations.component.ts +5 -1
@@ -0,0 +1,329 @@
1
+ import {
2
+ DEFAULT_WIDTHS,
3
+ defaultLayout,
4
+ generateSectionId,
5
+ isContainer,
6
+ MAX_COLUMNS,
7
+ MAX_CONTAINER_LEVELS,
8
+ MAX_SECTIONS,
9
+ type PageContainerLayout,
10
+ type PageSection,
11
+ type PageSectionType,
12
+ sanitiseWidths,
13
+ } from './page.types';
14
+
15
+ /** Clamps a requested column count into the supported range. */
16
+ function columnCount(columns: number): number {
17
+ return Math.min(Math.max(Math.floor(columns) || 1, 1), MAX_COLUMNS);
18
+ }
19
+
20
+ /** Drops trailing cells that hold nothing. Interior empties are meaningful and kept. */
21
+ export function trimTrailingEmptyCells(cells: PageSection[][]): PageSection[][] {
22
+ let end = cells.length;
23
+ while (end > 0 && cells[end - 1].length === 0) end--;
24
+ return cells.slice(0, end);
25
+ }
26
+
27
+ /** Pads to a whole number of rows, always leaving at least one row to drop into. */
28
+ export function padCells(cells: PageSection[][], columns: number): PageSection[][] {
29
+ const cols = columnCount(columns);
30
+ const remainder = cells.length % cols;
31
+ const pad = cells.length === 0 ? cols : remainder === 0 ? 0 : cols - remainder;
32
+ return [...cells, ...Array.from({length: pad}, () => [] as PageSection[])];
33
+ }
34
+
35
+ /**
36
+ * The canonical cell list for a container. A growable container never keeps a
37
+ * trailing all-empty row (the renderer supplies the `+` row itself, so keeping
38
+ * one would show two); a fixed container's trailing empty row is its layout and
39
+ * survives untouched.
40
+ */
41
+ export function normaliseCells(cells: PageSection[][], columns: number, grow: boolean): PageSection[][] {
42
+ return padCells(grow ? trimTrailingEmptyCells(cells) : cells, columns);
43
+ }
44
+
45
+ /**
46
+ * Re-chunks the same ordered list of stacks into a different column count. Only
47
+ * trailing empties are dropped, so no section can be lost by a column change.
48
+ */
49
+ export function recolumnCells(cells: PageSection[][], columns: number): PageSection[][] {
50
+ return padCells(trimTrailingEmptyCells(cells), columns);
51
+ }
52
+
53
+ /** Row-major view of the flat cell list, for rendering. */
54
+ export function cellRows(cells: PageSection[][], columns: number): PageSection[][][] {
55
+ const cols = columnCount(columns);
56
+ const rows: PageSection[][][] = [];
57
+ for (let i = 0; i < cells.length; i += cols) rows.push(cells.slice(i, i + cols));
58
+ return rows;
59
+ }
60
+
61
+ export function containerWidths(section: PageSection, type?: PageSectionType): number[] {
62
+ return sanitiseWidths(section.layout?.widths ?? type?.defaultWidths ?? [...DEFAULT_WIDTHS]);
63
+ }
64
+
65
+ export function containerColumns(section: PageSection, type?: PageSectionType): number {
66
+ return containerWidths(section, type).length;
67
+ }
68
+
69
+ export function containerGrow(section: PageSection, type?: PageSectionType): boolean {
70
+ return section.layout?.grow ?? Boolean(type?.defaultGrow);
71
+ }
72
+
73
+ /** A container's cells, normalised. Empty for a non-container. */
74
+ export function containerCells(section: PageSection, type?: PageSectionType): PageSection[][] {
75
+ if (!isContainer(type)) return [];
76
+ return normaliseCells(section.cells ?? [], containerColumns(section, type), containerGrow(section, type));
77
+ }
78
+
79
+ /** Depth-first search across top-level sections and every cell stack. */
80
+ export function findSection(sections: PageSection[], id: string | null): PageSection | undefined {
81
+ if (!id) return undefined;
82
+ for (const section of sections) {
83
+ if (section.id === id) return section;
84
+ for (const cell of section.cells ?? []) {
85
+ const hit = findSection(cell, id);
86
+ if (hit) return hit;
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /** New section array with `id` replaced by `patch(section)`, wherever it lives. */
93
+ export function patchSection(
94
+ sections: PageSection[],
95
+ id: string,
96
+ patch: (section: PageSection) => PageSection
97
+ ): PageSection[] {
98
+ return sections.map(section => {
99
+ if (section.id === id) return patch(section);
100
+ if (!section.cells) return section;
101
+ return {...section, cells: section.cells.map(cell => patchSection(cell, id, patch))};
102
+ });
103
+ }
104
+
105
+ /** Detaches a section from wherever it lives, returning it and the remaining tree. */
106
+ export function extractSection(
107
+ sections: PageSection[],
108
+ id: string
109
+ ): [PageSection | undefined, PageSection[]] {
110
+ let removed: PageSection | undefined;
111
+ const out: PageSection[] = [];
112
+ for (const section of sections) {
113
+ if (section.id === id) {
114
+ removed = section;
115
+ continue;
116
+ }
117
+ if (section.cells) {
118
+ const cells = section.cells.map(cell => {
119
+ const [hit, rest] = extractSection(cell, id);
120
+ if (hit) removed = hit;
121
+ return rest;
122
+ });
123
+ out.push({...section, cells});
124
+ } else {
125
+ out.push(section);
126
+ }
127
+ }
128
+ return [removed, out];
129
+ }
130
+
131
+ /** Inserts a section into a container's cell at a stack position. */
132
+ export function insertIntoCell(
133
+ sections: PageSection[],
134
+ containerId: string,
135
+ cellIndex: number,
136
+ insertIndex: number,
137
+ section: PageSection,
138
+ columns: number
139
+ ): PageSection[] {
140
+ return patchSection(sections, containerId, container => {
141
+ const cells = (container.cells ?? []).map(cell => [...cell]);
142
+ const target = Number.isFinite(cellIndex) ? Math.max(0, Math.floor(cellIndex)) : 0;
143
+ while (cells.length <= target) cells.push([]);
144
+ const cell = cells[target];
145
+ cell.splice(Math.max(0, Math.min(insertIndex, cell.length)), 0, section);
146
+ return {...container, cells: padCells(cells, columns)};
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Nesting level of the container with this id: 1 at the top level, 2 inside a
152
+ * cell, 0 when not found. Only containers have cells, so every cell level is a
153
+ * container level.
154
+ */
155
+ export function containerDepth(sections: PageSection[], containerId: string, depth = 1): number {
156
+ for (const section of sections) {
157
+ if (section.id === containerId) return depth;
158
+ for (const cell of section.cells ?? []) {
159
+ const hit = containerDepth(cell, containerId, depth + 1);
160
+ if (hit) return hit;
161
+ }
162
+ }
163
+ return 0;
164
+ }
165
+
166
+ /** Deep copy with a fresh id for the section and every descendant. */
167
+ export function cloneWithNewIds(section: PageSection): PageSection {
168
+ const reid = (s: PageSection): PageSection => ({
169
+ ...structuredClone(s),
170
+ id: generateSectionId(),
171
+ ...(s.cells ? {cells: s.cells.map(cell => cell.map(reid))} : {}),
172
+ });
173
+ return reid(section);
174
+ }
175
+
176
+ /** Resolves a type key to its registered type — the registry's `get`. */
177
+ export type TypeLookup = (type: string) => PageSectionType | undefined;
178
+
179
+ /**
180
+ * Height of a container's subtree: 1 for a container with no nested containers,
181
+ * otherwise 1 + the tallest nested container's height. 0 for a non-container, so
182
+ * it composes with containerDepth to bound how deep a moved subtree would reach:
183
+ * dropping a section into a container at depth d puts the section's own deepest
184
+ * descendant at d + containerHeight(section).
185
+ */
186
+ export function containerHeight(section: PageSection): number {
187
+ if (!section.cells) return 0;
188
+ const nested = section.cells.flat().map(containerHeight);
189
+ return 1 + Math.max(0, ...nested);
190
+ }
191
+
192
+ /**
193
+ * Re-applies each container's own grow/columns normalisation throughout the
194
+ * tree, so a growable container never carries a trailing all-empty row once
195
+ * committed — not only when read out via containerCells. A section whose
196
+ * current type isn't a registered container is left untouched, preserving its
197
+ * cells verbatim per the unknown-type contract.
198
+ */
199
+ export function normaliseGrowth(sections: PageSection[], lookup: TypeLookup): PageSection[] {
200
+ return sections.map(section => {
201
+ if (!section.cells) return section;
202
+ const type = lookup(section.type);
203
+ if (!isContainer(type)) return section;
204
+ const cells = normaliseCells(section.cells, containerColumns(section, type), containerGrow(section, type))
205
+ .map(cell => normaliseGrowth(cell, lookup));
206
+ return {...section, cells};
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Rewrites the pre-cells `children` shape as `cells`. The old grid was always
212
+ * DEFAULT_WIDTHS wide, and the slot count was the layout, so cells are padded
213
+ * out to the declared slot count, then rounded up to a whole number of
214
+ * DEFAULT_WIDTHS-wide rows; never trimmed.
215
+ */
216
+ export function migrateSection(section: PageSection, type?: PageSectionType): PageSection {
217
+ if (!section.children) return section;
218
+ const {children, ...rest} = section;
219
+ const slots = type?.slots ?? children.length;
220
+ const cells = Array.from({length: slots}, (_, i) => {
221
+ const child = children[i];
222
+ return child && typeof child.type === 'string' ? [child] : [];
223
+ });
224
+ return {
225
+ ...rest,
226
+ layout: section.layout ?? {widths: [...DEFAULT_WIDTHS], grow: false},
227
+ cells: padCells(cells, DEFAULT_WIDTHS.length),
228
+ };
229
+ }
230
+
231
+ function isSectionLike(value: unknown): value is PageSection {
232
+ return Boolean(value) && typeof value === 'object' && typeof (value as PageSection).type === 'string';
233
+ }
234
+
235
+ /**
236
+ * Normalises externally supplied sections: migrates the old shape, gives every
237
+ * section a unique id, drops malformed entries, sanitises layouts, caps nesting
238
+ * and clamps sizes. Pure — warnings are returned for the caller to log.
239
+ */
240
+ export function normaliseSections(
241
+ sections: unknown,
242
+ lookup: TypeLookup
243
+ ): {sections: PageSection[]; warnings: string[]} {
244
+ const warnings: string[] = [];
245
+ const seen = new Set<string>();
246
+ // A running admitted-section budget shared across every level, so 500 containers
247
+ // of 500 cells each can't slip past MAX_SECTIONS by hiding depth in nesting —
248
+ // only the top-level count was ever bounded before.
249
+ let remaining = MAX_SECTIONS;
250
+ let budgetExceeded = false;
251
+
252
+ const walk = (raw: PageSection, depth: number): PageSection | undefined => {
253
+ if (remaining <= 0) {
254
+ budgetExceeded = true;
255
+ return undefined;
256
+ }
257
+ remaining--;
258
+ const type = lookup(raw.type);
259
+ const migrated = migrateSection(raw, type);
260
+ const id = !migrated.id || seen.has(migrated.id) ? generateSectionId() : migrated.id;
261
+ seen.add(id);
262
+
263
+ const out: PageSection = {
264
+ id,
265
+ type: migrated.type,
266
+ label: migrated.label,
267
+ data: structuredClone(migrated.data ?? {}),
268
+ };
269
+
270
+ if (!migrated.cells) return out;
271
+
272
+ // A container past the cap keeps its identity but not its contents, so a
273
+ // malformed page loses as little as possible without unbounded recursion.
274
+ if (depth > MAX_CONTAINER_LEVELS) {
275
+ warnings.push(`container "${migrated.type}" exceeds ${MAX_CONTAINER_LEVELS} nesting levels; its cells were dropped`);
276
+ out.layout = sanitiseLayout(migrated.layout, type, warnings);
277
+ out.cells = [];
278
+ return out;
279
+ }
280
+
281
+ const layout = sanitiseLayout(migrated.layout, type, warnings);
282
+ // Slice to a whole number of rows (not MAX_SECTIONS itself) so the padding
283
+ // below is a no-op and the clamped length never overshoots MAX_SECTIONS.
284
+ const cols = layout.widths.length;
285
+ const maxCells = Math.floor(MAX_SECTIONS / cols) * cols;
286
+ let rawCells = migrated.cells;
287
+ if (rawCells.length > maxCells) {
288
+ warnings.push(`container "${migrated.type}" declared ${rawCells.length} cells; keeping the first ${maxCells}`);
289
+ rawCells = rawCells.slice(0, maxCells);
290
+ }
291
+
292
+ out.layout = layout;
293
+ out.cells = normaliseCells(
294
+ rawCells.map(cell => (Array.isArray(cell)
295
+ ? cell.filter(isSectionLike).map(child => walk(child, depth + 1)).filter(isDefined)
296
+ : [])),
297
+ layout.widths.length,
298
+ layout.grow
299
+ );
300
+ return out;
301
+ };
302
+
303
+ const incoming = (Array.isArray(sections) ? sections : []).filter(isSectionLike);
304
+ const kept = incoming.map(section => walk(section, 1)).filter(isDefined);
305
+ if (budgetExceeded) {
306
+ warnings.push(`page tree has more than ${MAX_SECTIONS} sections including nested ones; the rest were dropped`);
307
+ }
308
+ return {sections: kept, warnings};
309
+ }
310
+
311
+ function isDefined<T>(value: T | undefined): value is T {
312
+ return value !== undefined;
313
+ }
314
+
315
+ function sanitiseLayout(
316
+ layout: PageContainerLayout | undefined,
317
+ type: PageSectionType | undefined,
318
+ warnings: string[]
319
+ ): PageContainerLayout {
320
+ const seeded = defaultLayout(type);
321
+ if (!layout) return seeded;
322
+ const widths = sanitiseWidths(layout.widths);
323
+ const raw = layout.widths;
324
+ const unchanged = Array.isArray(raw) && raw.length === widths.length && raw.every((w, i) => w === widths[i]);
325
+ if (!unchanged) {
326
+ warnings.push(`container layout had unusable widths; corrected to ${widths.join(' ')}`);
327
+ }
328
+ return {widths, grow: Boolean(layout.grow)};
329
+ }
@@ -8,10 +8,25 @@ export interface PageSection {
8
8
  label?: string;
9
9
  /** Section content, keyed by field name (the inspector's `name` attributes). */
10
10
  data: Record<string, unknown>;
11
- /** Slot contents for container sections, sized to the type's `slots`. Empty slots are null. */
11
+ /** Container instances only: the editor-chosen layout. */
12
+ layout?: PageContainerLayout;
13
+ /** Container instances only: one ordered stack per cell, row-major. */
14
+ cells?: PageSection[][];
15
+ /**
16
+ * @deprecated The pre-cells fixed-slot shape. Read on load and migrated to
17
+ * `cells`; never written back.
18
+ */
12
19
  children?: (PageSection | null)[];
13
20
  }
14
21
 
22
+ /** A container instance's editor-chosen layout. */
23
+ export interface PageContainerLayout {
24
+ /** One weight per column; the array length IS the column count. */
25
+ widths: number[];
26
+ /** When true the builder offers a trailing empty row instead of a pinned cell count. */
27
+ grow: boolean;
28
+ }
29
+
15
30
  /** The complete serialisable state of a page. Order = render order. */
16
31
  export interface PageState {
17
32
  sections: PageSection[];
@@ -38,18 +53,65 @@ export interface PageSectionType {
38
53
  configTemplate?: HTMLTemplateElement;
39
54
  /** Programmatic inspector body — takes precedence over `configTemplate`. */
40
55
  renderConfig?: (section: PageSection, update: (data: Record<string, unknown>) => void) => TemplateResult;
56
+ /** Marks this type a container: its card renders a grid of cells on the canvas. */
57
+ container?: boolean;
41
58
  /**
42
- * Number of child slots this section offers on the canvas (a container tile);
43
- * rendered as a 3-column grid. Containers cannot be placed inside other containers.
59
+ * Column weights a new instance of this container starts with. Stored as parsed, not
60
+ * guaranteed sanitised route through `sanitiseWidths`/`defaultLayout` before use.
61
+ */
62
+ defaultWidths?: number[];
63
+ /** Whether a new instance of this container starts growable. */
64
+ defaultGrow?: boolean;
65
+ /**
66
+ * @deprecated Use `container` with `columns`/`widths`. Read as a container of
67
+ * `DEFAULT_WIDTHS` columns with the cell count pinned to this number.
44
68
  */
45
69
  slots?: number;
46
- /** Section type keys allowed in this container's slots. Omit to allow any non-container type. */
70
+ /**
71
+ * Section type keys allowed in this container's cells. On a `container` type,
72
+ * omitting it allows any type, subject to the nesting cap — that is how nesting
73
+ * is reachable without enumerating types. The deprecated `slots=` alias keeps the
74
+ * old rule instead: omitting it there allows any non-container type.
75
+ */
47
76
  accepts?: string[];
48
77
  }
49
78
 
50
- /** A container section's slot contents, padded/truncated to the type's slot count. */
51
- export function sectionChildren(section: PageSection, type: PageSectionType): (PageSection | null)[] {
52
- return Array.from({length: type.slots ?? 0}, (_, i) => section.children?.[i] ?? null);
79
+ /** Beyond 6 columns a card is under ~170px on the 1024px canvas — unreadable. */
80
+ export const MAX_COLUMNS = 6;
81
+ /** A single weight beyond 12 makes the other columns unusably thin. */
82
+ export const MAX_WIDTH = 12;
83
+ /** Container nesting levels: a top-level container is 1, one inside a cell is 2. */
84
+ export const MAX_CONTAINER_LEVELS = 2;
85
+ /** Global section budget; also the sanity clamp on an incoming `cells` length. */
86
+ export const MAX_SECTIONS = 500;
87
+ /** What a bare `container` declaration seeds. */
88
+ export const DEFAULT_WIDTHS: readonly number[] = [1, 1, 1];
89
+
90
+ /** Container-ness is a property of the registered type, never of the instance. */
91
+ export function isContainer(type: PageSectionType | undefined): boolean {
92
+ return Boolean(type?.container ?? (type?.slots !== undefined && type.slots > 0));
93
+ }
94
+
95
+ /**
96
+ * Coerces a weights list into a usable one: each entry a whole number from 1 to
97
+ * MAX_WIDTH (anything unusable becomes 1), at most MAX_COLUMNS entries, falling
98
+ * back to DEFAULT_WIDTHS only when nothing usable remains.
99
+ */
100
+ export function sanitiseWidths(raw: unknown): number[] {
101
+ const list = Array.isArray(raw) ? raw.slice(0, MAX_COLUMNS) : [];
102
+ const clean = list.map(entry => {
103
+ const n = typeof entry === 'number' && Number.isFinite(entry) ? Math.floor(entry) : 1;
104
+ return Math.min(Math.max(n, 1), MAX_WIDTH);
105
+ });
106
+ return clean.length ? clean : [...DEFAULT_WIDTHS];
107
+ }
108
+
109
+ /** The layout a newly placed instance of a container type starts with. */
110
+ export function defaultLayout(type: PageSectionType | undefined): PageContainerLayout {
111
+ return {
112
+ widths: sanitiseWidths(type?.defaultWidths ?? [...DEFAULT_WIDTHS]),
113
+ grow: Boolean(type?.defaultGrow),
114
+ };
53
115
  }
54
116
 
55
117
  /** Drag-and-drop MIME carrying a section type id from the palette to the canvas. */
@@ -85,6 +147,12 @@ function optionLabel(type: PageSectionType | undefined, name: string, value: str
85
147
  * so a tile reads as its linked item however its other fields were filled in.
86
148
  */
87
149
  export function sectionSummary(section: PageSection, type?: PageSectionType): string {
150
+ if (isContainer(type)) {
151
+ const columns = sanitiseWidths(section.layout?.widths ?? type?.defaultWidths).length;
152
+ const count = (section.cells ?? []).reduce((n, cell) => n + cell.length, 0);
153
+ const col = `${columns} column${columns === 1 ? '' : 's'}`;
154
+ return `${col} · ${count} section${count === 1 ? '' : 's'}`;
155
+ }
88
156
  const strings = Object.entries(section.data)
89
157
  .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '');
90
158
  for (const [name, value] of strings) {