@bloklabs/angular 1.4.2 → 1.4.4

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.
@@ -359,6 +359,77 @@ const generateId = (prefix = '') => {
359
359
  return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`;
360
360
  };
361
361
 
362
+ /**
363
+ * Flatten a hierarchical block spec (nodes with `children`) into the flat DFS
364
+ * pre-order `OutputBlockData[]` Blok stores, wiring every node's `parent` and
365
+ * `content` id links.
366
+ *
367
+ * This is the pure counterpart of the live `insertTree` mutation: the same DFS,
368
+ * without an editor. Use it to seed nested content — columns, tables, a whole
369
+ * document — without hand-authoring `parent`/`content` arrays:
370
+ *
371
+ * @example
372
+ * new Blok({
373
+ * data: {
374
+ * blocks: flattenTree([
375
+ * { type: 'column_list', children: [
376
+ * { type: 'column', children: [{ type: 'paragraph', data: { text: 'L' } }] },
377
+ * { type: 'column', children: [{ type: 'paragraph', data: { text: 'R' } }] },
378
+ * ] },
379
+ * ]),
380
+ * },
381
+ * });
382
+ *
383
+ * @param spec - a single root node or an array of root nodes.
384
+ * @param options - `parentId` sets the `parent` of the root node(s);
385
+ * `generateId` overrides id generation for nodes that omit an `id` (default:
386
+ * Blok's nanoid scheme).
387
+ * @returns DFS pre-order blocks with `parent`/`content` wired. Leaves omit the
388
+ * empty `content` array, matching the documented `OutputBlockData` shape.
389
+ * @throws if an explicit `id` is reused within the spec — a duplicate id would
390
+ * corrupt every id-keyed lookup, so it is surfaced loudly rather than encoded.
391
+ */
392
+ function flattenTree(spec, options = {}) {
393
+ const generateId = options.generateId ?? generateBlockId;
394
+ const rootParent = options.parentId ?? undefined;
395
+ const flat = [];
396
+ const usedIds = new Set();
397
+ // Pre-order DFS: push self FIRST, then recurse each child, so the flat array
398
+ // is DFS-contiguous. `content` is filled as each child is visited and returns
399
+ // its id; it is omitted for leaves.
400
+ const visit = (node, parent) => {
401
+ const id = node.id ?? generateId();
402
+ if (usedIds.has(id)) {
403
+ throw new Error(`flattenTree: duplicate block id "${id}" — every block id must be unique.`);
404
+ }
405
+ usedIds.add(id);
406
+ const childIds = [];
407
+ const flatNode = {
408
+ id,
409
+ // `type` is OMITTED (not present-with-undefined) when absent so core's
410
+ // `type || defaultBlock` fallback resolves the default block cleanly.
411
+ ...(node.type !== undefined ? { type: node.type } : {}),
412
+ data: node.data ?? {},
413
+ ...(node.tunes !== undefined ? { tunes: node.tunes } : {}),
414
+ ...(parent !== undefined ? { parent } : {}),
415
+ };
416
+ flat.push(flatNode);
417
+ for (const child of node.children ?? []) {
418
+ childIds.push(visit(child, id));
419
+ }
420
+ // Only wire `content` when there are children — leaves stay clean.
421
+ if (childIds.length > 0) {
422
+ flatNode.content = childIds;
423
+ }
424
+ return id;
425
+ };
426
+ const roots = Array.isArray(spec) ? spec : [spec];
427
+ for (const root of roots) {
428
+ visit(root, rootParent);
429
+ }
430
+ return flat;
431
+ }
432
+
362
433
  /**
363
434
  * Enumerate the editor's blocks once into BlockNode records, in flat order,
364
435
  * deriving each node's contentIds from the children that name it as parent.
@@ -1218,54 +1289,41 @@ const createBlocksApiForEditor = (editor, onRead = () => undefined) => {
1218
1289
  return null;
1219
1290
  }
1220
1291
  }
1221
- // Every node needs a stable id BEFORE flattening so parent/content links
1222
- // can reference siblings/children. Respect an explicit node id; generate
1223
- // one (core's nanoid scheme) otherwise.
1224
- const flat = [];
1225
- // Collision guard: an explicit id that already exists in the tree (or is
1226
- // reused within this same spec) would create a duplicate-id block,
1227
- // corrupting every id-keyed lookup (getById, parent/content links). A tree
1228
- // insert always creates fresh blocks (it is NOT insert-if-absent), so a
1229
- // collision is rejected up front — nothing is inserted and null is
1230
- // returned, mirroring insert's null contract. Generated ids never collide.
1231
- // Mutable property on a const holder (no `let`): flipped from inside the
1232
- // recursive visit when a colliding explicit id is seen.
1233
- const usedIds = new Set();
1234
- const collision = { hit: false };
1235
- // Pre-order DFS: push self FIRST, then recurse each child, so the flat
1236
- // array is DFS-contiguous (the invariant core's insertMany + the hook's
1237
- // getChildren depend on). `content` is the live child-id array, filled as
1238
- // each child is visited and returns its id. The root's `parent` is the
1239
- // resolved placement parent (undefined for root level); children's `parent`
1240
- // is their enclosing node's id.
1241
- const visit = (node, parent) => {
1242
- if (node.id !== undefined && (usedIds.has(node.id) || getById(node.id) !== null)) {
1243
- collision.hit = true;
1292
+ // Flatten the nested spec to a DFS pre-order array with wired
1293
+ // `parent`/`content` links — the same transform used to seed nested data,
1294
+ // shared as the pure `flattenTree` helper. `parentId` becomes the root's
1295
+ // `parent`. flattenTree throws on an id reused WITHIN the spec (a duplicate
1296
+ // would corrupt every id-keyed lookup); a tree insert always creates fresh
1297
+ // blocks (NOT insert-if-absent), so that is rejected up front — null,
1298
+ // mirroring insert's null contract.
1299
+ const flat = (() => {
1300
+ try {
1301
+ return flattenTree(spec, { parentId: parentId ?? undefined });
1244
1302
  }
1245
- const id = node.id ?? generateBlockId();
1246
- usedIds.add(id);
1247
- const content = [];
1248
- const flatNode = {
1249
- id,
1250
- // `type` is OMITTED (not present-with-undefined) when absent so core's
1251
- // `type || defaultBlock` fallback resolves the default block cleanly.
1252
- ...(node.type !== undefined ? { type: node.type } : {}),
1253
- data: node.data ?? {},
1254
- content,
1255
- ...(node.tunes !== undefined ? { tunes: node.tunes } : {}),
1256
- ...(parent !== undefined ? { parent } : {}),
1257
- };
1258
- flat.push(flatNode);
1259
- for (const child of node.children ?? []) {
1260
- content.push(visit(child, id));
1303
+ catch {
1304
+ return null;
1261
1305
  }
1262
- return id;
1263
- };
1264
- const rootId = visit(spec, parentId ?? undefined);
1265
- // A colliding explicit id is rejected before any insert (see usedIds doc).
1266
- if (collision.hit) {
1306
+ })();
1307
+ if (flat === null) {
1267
1308
  return null;
1268
1309
  }
1310
+ // External collision: an explicit id already present in the live tree would
1311
+ // also create a duplicate-id block. Snapshot the existing ids once and
1312
+ // reject if any flat node reuses one. Generated ids never collide.
1313
+ const existingIds = new Set(snapshotNodes(reader).map((n) => n.id));
1314
+ if (flat.some((node) => existingIds.has(node.id))) {
1315
+ return null;
1316
+ }
1317
+ const rootId = flat[0].id;
1318
+ // flattenTree omits `content` on leaves (clean seed-data shape). insertMany
1319
+ // wants every node to carry an explicit child-id array — a leaf's
1320
+ // `contentIds: []` states "no children" rather than leaving it undefined —
1321
+ // so stamp the empty array back on before handing the batch to core.
1322
+ for (const node of flat) {
1323
+ if (node.content === undefined) {
1324
+ node.content = [];
1325
+ }
1326
+ }
1269
1327
  const flatIndex = resolveInsertIndex(reader, parentId, position);
1270
1328
  // ONE transact for the whole subtree → a single atomic undo step. Core's
1271
1329
  // insertMany composes EVERY node before inserting any, so an unknown tool
@@ -1677,6 +1735,226 @@ function deepEqual(a, b) {
1677
1735
  return aKeys.every((key) => Object.prototype.hasOwnProperty.call(bObj, key) && deepEqual(aObj[key], bObj[key]));
1678
1736
  }
1679
1737
 
1738
+ /**
1739
+ * Public utilities for working with saved editor documents (`OutputData`).
1740
+ *
1741
+ * Consumers persisting editor content need two recurring predicates that the
1742
+ * editor itself already relies on internally:
1743
+ *
1744
+ * - structural equality, to dedupe the `data → render → onSave → data` echo
1745
+ * round-trip without clobbering the caret;
1746
+ * - emptiness, to decide whether a document carries any user content
1747
+ * (placeholder states, "save" button gating, skipping empty submissions).
1748
+ *
1749
+ * Both accept the loose wire shape (`data: null`, `id: null`, nullish
1750
+ * documents) so backend DTOs can be passed as-is.
1751
+ */
1752
+ /**
1753
+ * Compares two blocks structurally. The `id` participates only when BOTH
1754
+ * blocks carry one: the editor mints a fresh id whenever content arrives
1755
+ * without one (see {@link normalizeOutputBlocks}), so an id-less origin
1756
+ * document must still compare equal to its saved echo.
1757
+ * @param a - first block to compare
1758
+ * @param b - second block to compare
1759
+ * @returns true when the blocks are structurally equal
1760
+ */
1761
+ function equalsOutputBlock(a, b) {
1762
+ const { id: idA, ...restA } = a;
1763
+ const { id: idB, ...restB } = b;
1764
+ const hasIdA = typeof idA === 'string' && idA !== '';
1765
+ const hasIdB = typeof idB === 'string' && idB !== '';
1766
+ if (hasIdA && hasIdB && idA !== idB) {
1767
+ return false;
1768
+ }
1769
+ return deepEqual(restA, restB);
1770
+ }
1771
+ /**
1772
+ * The tool a pristine editor seeds its first (empty) block with. An empty block
1773
+ * of this type is UI scaffolding, not user content — see
1774
+ * {@link EqualsOutputDataOptions.ignoreEmptyDefaultBlocks}.
1775
+ */
1776
+ const DEFAULT_BLOCK_TYPE = 'paragraph';
1777
+ /**
1778
+ * True for an empty block of the default paragraph tool — the editor's own
1779
+ * scaffolding, not user content.
1780
+ * @param block - block to inspect
1781
+ * @returns true when the block is an empty default paragraph
1782
+ */
1783
+ function isEmptyDefaultBlock(block) {
1784
+ return block.type === DEFAULT_BLOCK_TYPE && isEmptyValue(block.data);
1785
+ }
1786
+ /**
1787
+ * Resolves the blocks to compare for a document, dropping empty default blocks
1788
+ * when {@link EqualsOutputDataOptions.ignoreEmptyDefaultBlocks} is set.
1789
+ * @param data - document whose blocks are collected
1790
+ * @param options - comparison options
1791
+ * @returns the blocks to compare
1792
+ */
1793
+ function comparableBlocks(data, options) {
1794
+ const blocks = data?.blocks ?? [];
1795
+ if (options?.ignoreEmptyDefaultBlocks === true) {
1796
+ return blocks.filter((block) => !isEmptyDefaultBlock(block));
1797
+ }
1798
+ return blocks;
1799
+ }
1800
+ /**
1801
+ * Structural equality for saved documents. Compares the `blocks` arrays
1802
+ * deeply; the volatile `time` and `version` envelope fields are ignored, so a
1803
+ * document round-tripped through `save()` compares equal to its echo. Block
1804
+ * ids are compared only when both sides carry one — the editor mints fresh
1805
+ * ids for id-less content, so a legacy document still equals its saved echo.
1806
+ * Nullish documents compare equal to `{ blocks: [] }`.
1807
+ *
1808
+ * Pass `{ ignoreEmptyDefaultBlocks: true }` for dirty-vs-baseline checks so a
1809
+ * pristine editor (one empty default paragraph) equals a saved-empty baseline —
1810
+ * see {@link EqualsOutputDataOptions}.
1811
+ * @param a - first document to compare
1812
+ * @param b - second document to compare
1813
+ * @param options - comparison options
1814
+ * @returns true when both documents hold structurally equal blocks
1815
+ */
1816
+ function equalsOutputData(a, b, options) {
1817
+ const blocksA = comparableBlocks(a, options);
1818
+ const blocksB = comparableBlocks(b, options);
1819
+ return blocksA.length === blocksB.length && blocksA.every((block, index) => equalsOutputBlock(block, blocksB[index]));
1820
+ }
1821
+ /**
1822
+ * A bounded window of the editor's recently emitted `onSave` payloads, used by
1823
+ * the framework adapters to recognize controlled-`data` echoes. Deduping
1824
+ * against only the LAST emitted payload is not enough: a host that persists on
1825
+ * save and refetches can hand the adapter a STALE echo — an earlier save
1826
+ * arriving after a newer one already replaced the baseline — and re-rendering
1827
+ * it would clobber the caret and any content typed since. Matching is
1828
+ * structural ({@link equalsOutputData}), so envelopes reshaped in transit
1829
+ * (fresh `time`, stripped ids) still count as echoes.
1830
+ * @param capacity - number of payloads retained before the oldest is evicted
1831
+ * @returns the echo window
1832
+ */
1833
+ function createEmittedEchoWindow(capacity = 20) {
1834
+ const emitted = [];
1835
+ return {
1836
+ record(data) {
1837
+ emitted.push(data);
1838
+ if (emitted.length > capacity) {
1839
+ emitted.shift();
1840
+ }
1841
+ },
1842
+ matches(data) {
1843
+ return emitted.some((payload) => equalsOutputData(payload, data));
1844
+ },
1845
+ clear() {
1846
+ emitted.length = 0;
1847
+ },
1848
+ };
1849
+ }
1850
+ /**
1851
+ * A shared, deeply frozen empty document. Consumers persisting editor content
1852
+ * repeatedly hand-write `{ blocks: [] }` for cleared/pristine baselines; this
1853
+ * is the canonical value to use instead. Frozen (blocks array included) so a
1854
+ * shared reference can never be mutated into a stale non-empty baseline.
1855
+ */
1856
+ const EMPTY_OUTPUT_DATA = Object.freeze({
1857
+ // Freeze the array too, so a shared reference can't be mutated. The public
1858
+ // `blocks` type is mutable (`OutputBlockData[]`), so the frozen (readonly)
1859
+ // array is re-widened through `unknown` — runtime stays frozen.
1860
+ blocks: Object.freeze([]),
1861
+ });
1862
+ /**
1863
+ * Maps a controlled `data` value to something the editor's `render()` accepts.
1864
+ * A whole-document `null` — a controlled "clear to empty" — becomes
1865
+ * `{ blocks: [] }`; any real document passes through untouched.
1866
+ *
1867
+ * The framework adapters (React/Vue/Angular) route their reactive `data` path
1868
+ * through this so a `null` never reaches `render()`, whose strict guard reads
1869
+ * `data.blocks` and would throw on `null`. `undefined` (uncontrolled) is handled
1870
+ * by the adapters before this point, so it is intentionally not an input here.
1871
+ * @param data - a controlled document, or `null` for an empty document
1872
+ * @returns a document `render()` can consume
1873
+ */
1874
+ function toRenderableData(data) {
1875
+ return data === null ? { blocks: [] } : data;
1876
+ }
1877
+ /**
1878
+ * Normalizes blocks from the loose wire shape into the strict saved shape at
1879
+ * the editor's input boundaries: a `null`/missing `data` becomes `{}`, a
1880
+ * `null`/empty `id` is dropped so the block factory generates a fresh one.
1881
+ * Idempotent — strict blocks pass through unchanged (shallow-copied).
1882
+ * @param blocks - blocks in either the strict or the loose wire shape
1883
+ * @returns blocks in the strict saved shape
1884
+ */
1885
+ function normalizeOutputBlocks(blocks) {
1886
+ return blocks.map((block) => {
1887
+ const { id, data, ...rest } = block;
1888
+ return {
1889
+ ...rest,
1890
+ ...(typeof id === 'string' && id !== '' ? { id } : {}),
1891
+ data: data ?? {},
1892
+ };
1893
+ });
1894
+ }
1895
+ /**
1896
+ * Normalizes a whole loose wire document into the strict saved
1897
+ * {@link OutputData} shape at an input boundary. A nullish document becomes
1898
+ * `{ blocks: [] }`; `null` envelope fields (`time`/`version`) are dropped; each
1899
+ * block is passed through {@link normalizeOutputBlocks}, so `null`/missing
1900
+ * `data` becomes `{}` and `null`/empty ids are dropped for regeneration.
1901
+ *
1902
+ * Unlike a hand-written `blocks.map(...)` mapper, this preserves every block
1903
+ * passthrough field — `tunes`, `parent`, `content`, `indent`, edit metadata —
1904
+ * so a backend DTO can be turned into strict `OutputData` without silently
1905
+ * losing hierarchy or tunes. Idempotent: a strict document passes through
1906
+ * unchanged (shallow-copied).
1907
+ * @param data - a document in the strict or loose wire shape, or nullish
1908
+ * @returns the document in the strict saved shape
1909
+ */
1910
+ function normalizeOutputData(data) {
1911
+ if (data === null || data === undefined) {
1912
+ return { blocks: [] };
1913
+ }
1914
+ return {
1915
+ ...(typeof data.version === 'string' ? { version: data.version } : {}),
1916
+ ...(typeof data.time === 'number' ? { time: data.time } : {}),
1917
+ blocks: normalizeOutputBlocks(data.blocks),
1918
+ };
1919
+ }
1920
+ /**
1921
+ * True when the value carries no user content: blank/whitespace-only strings,
1922
+ * empty arrays, plain objects whose values are all empty, and nullish values.
1923
+ * Numbers and booleans are presentation metadata (`level`, `checked`, styles)
1924
+ * and never count as content on their own.
1925
+ * @param value - block data value to inspect
1926
+ * @returns true when the value carries no user content
1927
+ */
1928
+ function isEmptyValue(value) {
1929
+ if (value === null || value === undefined) {
1930
+ return true;
1931
+ }
1932
+ if (typeof value === 'string') {
1933
+ return value.trim() === '';
1934
+ }
1935
+ if (Array.isArray(value)) {
1936
+ return value.every(isEmptyValue);
1937
+ }
1938
+ if (typeof value === 'object') {
1939
+ return Object.values(value).every(isEmptyValue);
1940
+ }
1941
+ // Numbers, booleans and other primitives are metadata, not content.
1942
+ return true;
1943
+ }
1944
+ /**
1945
+ * True when the document carries no user content: it is nullish, has no
1946
+ * blocks, or every block's data holds only empty values (see
1947
+ * {@link isEmptyValue}). Note that content-less visual blocks (e.g. a divider,
1948
+ * whose data is `{}`) count as empty — check `blocks.length` when mere block
1949
+ * presence matters.
1950
+ * @param data - document to inspect
1951
+ * @returns true when the document carries no user content
1952
+ */
1953
+ function isEmptyOutputData(data) {
1954
+ const blocks = data?.blocks ?? [];
1955
+ return blocks.every((block) => isEmptyValue(block.data));
1956
+ }
1957
+
1680
1958
  // src/shared/prop-schema.ts
1681
1959
  /**
1682
1960
  * Fill a data object against the schema: every schema key present, incoming
@@ -2204,6 +2482,8 @@ class BlokEditorComponent {
2204
2482
  }
2205
2483
  // ---- Construction-only seed inputs (read once at construction) ----
2206
2484
  tools;
2485
+ /** Host-supplied per-type block migrations applied at load (merged across layers). */
2486
+ migrations;
2207
2487
  /**
2208
2488
  * Escape hatch: a full config object for keys without a dedicated input
2209
2489
  * (sanitizer, minHeight, …). Layered between provideBlok
@@ -2278,6 +2558,13 @@ class BlokEditorComponent {
2278
2558
  this.defaults.tools !== undefined) {
2279
2559
  cfg.tools = { ...this.defaults.tools, ...this.config?.tools, ...this.tools };
2280
2560
  }
2561
+ // migration registries merge like tools: shared defaults compose with
2562
+ // per-instance rules.
2563
+ if (this.migrations !== undefined ||
2564
+ this.config?.migrations !== undefined ||
2565
+ this.defaults.migrations !== undefined) {
2566
+ cfg.migrations = { ...this.defaults.migrations, ...this.config?.migrations, ...this.migrations };
2567
+ }
2281
2568
  const data = this.data$();
2282
2569
  if (data !== undefined) {
2283
2570
  cfg.data = data;
@@ -2470,7 +2757,9 @@ class BlokEditorComponent {
2470
2757
  return;
2471
2758
  }
2472
2759
  this.lastRenderedData = data;
2473
- this.renderChain = this.renderChain.catch(() => undefined).then(() => editor.render(data));
2760
+ // `data` may be null (a controlled "clear to empty"); render() throws on
2761
+ // null, so normalize null → { blocks: [] } at the boundary.
2762
+ this.renderChain = this.renderChain.catch(() => undefined).then(() => editor.render(toRenderableData(data)));
2474
2763
  });
2475
2764
  // Emit `ready` once per editor, after `instance()` is populated so a consumer
2476
2765
  // reading the component/ref instance inside the handler sees it.
@@ -2523,7 +2812,7 @@ class BlokEditorComponent {
2523
2812
  this.readOnly$.set(isDisabled);
2524
2813
  }
2525
2814
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: BlokEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2526
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.7", type: BlokEditorComponent, isStandalone: true, selector: "blok-editor", inputs: { onBeforeRender: "onBeforeRender", onBeforePaste: "onBeforePaste", onError: "onError", readOnly: "readOnly", hideToolbar: "hideToolbar", inlineToolbar: "inlineToolbar", theme: "theme", width: "width", placeholder: "placeholder", styleTokens: "styleTokens", i18n: "i18n", autofocus: "autofocus", data: "data", tools: "tools", config: "config", recreateKey: "recreateKey" }, outputs: { ready: "ready", dataChange: "dataChange", save: "save", change: "change", afterRender: "afterRender", themeChange: "themeChange", blocksRendered: "blocksRendered", blockRendered: "blockRendered" }, providers: [
2815
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.7", type: BlokEditorComponent, isStandalone: true, selector: "blok-editor", inputs: { onBeforeRender: "onBeforeRender", onBeforePaste: "onBeforePaste", onError: "onError", readOnly: "readOnly", hideToolbar: "hideToolbar", inlineToolbar: "inlineToolbar", theme: "theme", width: "width", placeholder: "placeholder", styleTokens: "styleTokens", i18n: "i18n", autofocus: "autofocus", data: "data", tools: "tools", migrations: "migrations", config: "config", recreateKey: "recreateKey" }, outputs: { ready: "ready", dataChange: "dataChange", save: "save", change: "change", afterRender: "afterRender", themeChange: "themeChange", blocksRendered: "blocksRendered", blockRendered: "blockRendered" }, providers: [
2527
2816
  {
2528
2817
  provide: NG_VALUE_ACCESSOR,
2529
2818
  useExisting: forwardRef(() => BlokEditorComponent),
@@ -2595,6 +2884,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
2595
2884
  type: Input
2596
2885
  }], tools: [{
2597
2886
  type: Input
2887
+ }], migrations: [{
2888
+ type: Input
2598
2889
  }], config: [{
2599
2890
  type: Input
2600
2891
  }], recreateKey: [{