@bloklabs/angular 1.7.0 → 1.9.0

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, Injector, createComponent, inject, ElementRef, NgZone, PLATFORM_ID, EnvironmentInjector, ApplicationRef, ErrorHandler, EventEmitter, signal, afterNextRender, Output, Input, Directive, computed, effect, forwardRef, ViewChild, ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, signal, inject, Injector, createComponent, ElementRef, NgZone, PLATFORM_ID, EnvironmentInjector, ApplicationRef, ErrorHandler, EventEmitter, afterNextRender, Output, Input, Directive, computed, effect, forwardRef, ViewChild, ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
3
3
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
4
  import { isPlatformBrowser } from '@angular/common';
5
5
  import { Blok } from '@bloklabs/core';
@@ -27,6 +27,42 @@ function provideBlok(defaults) {
27
27
  /** DI token carrying the per-block render context into the authored component. */
28
28
  const BLOK_BLOCK_CONTEXT = new InjectionToken('BLOK_BLOCK_CONTEXT');
29
29
 
30
+ // packages/angular/src/blok-instance.ts
31
+ /**
32
+ * DI token carrying the LIVE editor instance (as a signal) into every block
33
+ * mounted by `createAngularBlock`. `BlokContentDirective` publishes its own
34
+ * `instance` signal through it, so the value is null before the editor is ready
35
+ * and after teardown — matching the React/Vue adapters' pre-ready contract.
36
+ *
37
+ * Provided on the block's ELEMENT injector by the portal registry, so it is
38
+ * per-EDITOR: two editors on one page each publish their own instance.
39
+ */
40
+ const BLOK_EDITOR_INSTANCE = new InjectionToken('BLOK_EDITOR_INSTANCE');
41
+ /** Stable "no editor here" signal for components mounted outside an editor. */
42
+ const NO_EDITOR = signal(null).asReadonly();
43
+ /**
44
+ * The live Blok instance the component is mounted inside, as a signal, or a
45
+ * signal of null before it exists. Inside a `createAngularBlock` component this
46
+ * is the block's OWN editor — so a block can drive the tree it lives in without
47
+ * the host prop-drilling the instance into it:
48
+ *
49
+ * ```ts
50
+ * private readonly editor = injectBlokInstance();
51
+ * private readonly blocks = injectBlocks(this.editor);
52
+ * ```
53
+ *
54
+ * That pairing is also what makes a container block REACTIVE to its own child
55
+ * tree: `injectBlocks` refreshes on the editor's `block changed` event, which
56
+ * core emits for every structural mutation — including children the adapter
57
+ * itself never sees (a pasted paragraph, a Tab-indent from the keyboard).
58
+ *
59
+ * Call it in an injection context (a field initializer or the constructor).
60
+ * Outside an editor it returns a signal of null.
61
+ */
62
+ function injectBlokInstance() {
63
+ return inject(BLOK_EDITOR_INSTANCE, { optional: true }) ?? NO_EDITOR;
64
+ }
65
+
30
66
  // packages/angular/src/block-portal-registry.ts
31
67
  /**
32
68
  * Tool-config key carrying the editor's portal registry into a
@@ -41,8 +77,18 @@ const BLOK_PORTAL_REGISTRY_CONFIG_KEY = '__blokAngularPortalRegistry';
41
77
  * editor instance (associated via the registry map). The injectors/appRef are
42
78
  * passed in (captured by the directive in an injection context) because the
43
79
  * registry itself is built outside one.
80
+ *
81
+ * `editor` is the directive's own instance signal. It is passed at CREATION
82
+ * (before the editor exists) rather than set later precisely because it is a
83
+ * signal: blocks mounted during boot read null and re-read the real instance
84
+ * once it resolves.
85
+ * @param envInjector - the app environment injector (parent of each block's)
86
+ * @param appRef - the ApplicationRef each mounted block view is attached to
87
+ * @param errorHandler - where a throwing author component is reported
88
+ * @param editor - signal of the live Blok instance, published to blocks via
89
+ * {@link BLOK_EDITOR_INSTANCE}; omit for registries with no editor to publish
44
90
  */
45
- const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
91
+ const createBlockPortalRegistry = (envInjector, appRef, errorHandler, editor) => {
46
92
  const mounted = new Map();
47
93
  // Change detection runs outside NgZone here; a throwing author component must
48
94
  // degrade to a blank holder, not break core's block insertion.
@@ -54,7 +100,7 @@ const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
54
100
  errorHandler.handleError(error);
55
101
  }
56
102
  };
57
- const teardown = (id) => {
103
+ const teardown = (id, ownerHostEl) => {
58
104
  const entry = mounted.get(id);
59
105
  if (entry === undefined) {
60
106
  return;
@@ -62,6 +108,10 @@ const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
62
108
  const { ref, injector } = entry;
63
109
  // Capture host before deletion so we can clear it below.
64
110
  const hostEl = ref.location.nativeElement;
111
+ // A superseded owner may not destroy the mount that replaced it.
112
+ if (ownerHostEl !== undefined && hostEl !== ownerHostEl) {
113
+ return;
114
+ }
65
115
  mounted.delete(id);
66
116
  appRef.detachView(ref.hostView);
67
117
  ref.destroy();
@@ -84,7 +134,14 @@ const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
84
134
  // EnvironmentInjector. The shared app envInjector is the parent, so author
85
135
  // blocks still see app-level providers (HttpClient, etc.).
86
136
  const elementInjector = Injector.create({
87
- providers: [{ provide: BLOK_BLOCK_CONTEXT, useValue: entry.context }],
137
+ providers: [
138
+ { provide: BLOK_BLOCK_CONTEXT, useValue: entry.context },
139
+ // Per-EDITOR, so two editors on one page each publish their own
140
+ // instance to their own blocks.
141
+ ...(editor === undefined
142
+ ? []
143
+ : [{ provide: BLOK_EDITOR_INSTANCE, useValue: editor }]),
144
+ ],
88
145
  parent: envInjector,
89
146
  });
90
147
  safe(() => {
@@ -100,8 +157,8 @@ const createBlockPortalRegistry = (envInjector, appRef, errorHandler) => {
100
157
  ref.changeDetectorRef.detectChanges();
101
158
  });
102
159
  },
103
- unregister(id) {
104
- teardown(id);
160
+ unregister(id, hostEl) {
161
+ teardown(id, hostEl);
105
162
  },
106
163
  flush(id) {
107
164
  const entry = mounted.get(id);
@@ -219,7 +276,11 @@ class BlokContentDirective {
219
276
  }
220
277
  // Create the editor-scoped portal registry and thread it into every
221
278
  // Angular-block tool's config (vanilla tools pass through untouched).
222
- const registry = createBlockPortalRegistry(this.envInjector, this.appRef, this.errorHandler);
279
+ // `this.instance` is handed over at creation: it is a SIGNAL, so blocks
280
+ // mounted during boot read null and re-read the real editor once isReady
281
+ // resolves — no ordering problem, and `injectBlokInstance()` inside a block
282
+ // component tracks the swap.
283
+ const registry = createBlockPortalRegistry(this.envInjector, this.appRef, this.errorHandler, this.instance);
223
284
  this.registry = registry;
224
285
  merged.tools = injectPortalRegistry(merged.tools, registry);
225
286
  // Construct outside Angular's zone so the editor's internal DOM churn never
@@ -359,6 +420,39 @@ const generateId = (prefix = '') => {
359
420
  return `${prefix}${(Math.floor(Math.random() * ID_RANDOM_MULTIPLIER)).toString(HEXADECIMAL_RADIX)}`;
360
421
  };
361
422
 
423
+ /**
424
+ * Whether a spec node is a pre-flat run (`{ blocks: [...] }`) rather than a
425
+ * tree node. Keyed on the `blocks` array, the only field the two shapes cannot
426
+ * share.
427
+ * @param node - spec node to classify
428
+ */
429
+ const isRunSpec = (node) => {
430
+ return Array.isArray(node.blocks);
431
+ };
432
+ /**
433
+ * Wire-tolerant id read: `null` and `''` mean "absent", as everywhere else a
434
+ * loaded document is normalized.
435
+ * @param id - id as it arrived on a saved block
436
+ */
437
+ const nonEmptyId = (id) => {
438
+ return typeof id === 'string' && id !== '' ? id : undefined;
439
+ };
440
+ /**
441
+ * Reject an already-flat block passed where a TREE node is expected. A spec
442
+ * node has no `parent`/`content` fields, so such a block would be flattened
443
+ * with its links silently DROPPED — the structure it described is gone and
444
+ * nothing reports it. Pre-flat blocks belong in a run node instead.
445
+ * @param node - tree node about to be flattened
446
+ * @throws if the node carries link information a tree node cannot express
447
+ */
448
+ const assertNotPreFlat = (node) => {
449
+ const asFlat = node;
450
+ const carriesParent = typeof asFlat.parent === 'string' && asFlat.parent !== '';
451
+ const carriesContent = Array.isArray(asFlat.content) && asFlat.content.length > 0;
452
+ if (carriesParent || carriesContent) {
453
+ throw new Error(`flattenTree: block "${node.id ?? '(no id)'}" carries \`parent\`/\`content\` links, which a tree node cannot express — pass pre-flat blocks as a run node: { blocks: [...] }.`);
454
+ }
455
+ };
362
456
  /**
363
457
  * Flatten a hierarchical block spec (nodes with `children`) into the flat DFS
364
458
  * pre-order `OutputBlockData[]` Blok stores, wiring every node's `parent` and
@@ -380,29 +474,82 @@ const generateId = (prefix = '') => {
380
474
  * },
381
475
  * });
382
476
  *
383
- * @param spec - a single root node or an array of root nodes.
477
+ * Content that is not tree-shaped to begin with — an already-flat saved
478
+ * document a migration is splicing into a page — goes in as a RUN node,
479
+ * `{ blocks: [...] }`, at the root or as a child. A run is spliced verbatim:
480
+ * ids, `data`, `tunes` and existing `parent`/`content` links are kept, and only
481
+ * the blocks it left un-parented are re-parented onto the enclosing node.
482
+ *
483
+ * @example
484
+ * flattenTree({ type: 'column', children: [{ blocks: saved.blocks }] });
485
+ *
486
+ * @param spec - a single root node or an array of root nodes; each is either a
487
+ * tree node or a pre-flat run.
384
488
  * @param options - `parentId` sets the `parent` of the root node(s);
385
489
  * `generateId` overrides id generation for nodes that omit an `id` (default:
386
490
  * Blok's nanoid scheme).
387
491
  * @returns DFS pre-order blocks with `parent`/`content` wired. Leaves omit the
388
492
  * empty `content` array, matching the documented `OutputBlockData` shape.
389
493
  * @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.
494
+ * corrupt every id-keyed lookup, so it is surfaced loudly rather than encoded
495
+ * — or if a TREE node carries `parent`/`content` links, which it cannot
496
+ * express (that is a pre-flat block; wrap it in a run node).
391
497
  */
392
498
  function flattenTree(spec, options = {}) {
393
499
  const generateId = options.generateId ?? generateBlockId;
394
500
  const rootParent = options.parentId ?? undefined;
395
501
  const flat = [];
396
502
  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();
503
+ /**
504
+ * Resolve a node's id and reserve it. A duplicate would corrupt every
505
+ * id-keyed lookup, so it is surfaced loudly rather than encoded.
506
+ * @param explicitId - id the spec carried, if any
507
+ */
508
+ const claimId = (explicitId) => {
509
+ const id = explicitId ?? generateId();
402
510
  if (usedIds.has(id)) {
403
511
  throw new Error(`flattenTree: duplicate block id "${id}" — every block id must be unique.`);
404
512
  }
405
513
  usedIds.add(id);
514
+ return id;
515
+ };
516
+ /**
517
+ * Splice a pre-flat run in verbatim: every block keeps its id, `data`,
518
+ * `tunes` and its existing `parent`/`content` links, and only the blocks the
519
+ * run left un-parented are re-parented onto the enclosing node. Mirrors
520
+ * `useBlocks.insertMarkdown`, which nests a converted run the same way.
521
+ * @param run - the run node
522
+ * @param parent - id of the node the run is spliced into (undefined at root)
523
+ * @returns ids of the run's top-level blocks, in run order
524
+ */
525
+ const visitRun = (run, parent) => {
526
+ const rootIds = [];
527
+ for (const block of run.blocks) {
528
+ const { parent: incomingParent, id: incomingId, content: incomingContent, data, ...rest } = block;
529
+ const id = claimId(nonEmptyId(incomingId));
530
+ const incomingParentId = nonEmptyId(incomingParent);
531
+ const isRunRoot = incomingParentId === undefined;
532
+ const resolvedParent = isRunRoot ? parent : incomingParentId;
533
+ flat.push({
534
+ ...rest,
535
+ id,
536
+ data: data ?? {},
537
+ // `null`/`[]` content means "no children" — omitted, like a tree leaf.
538
+ ...(Array.isArray(incomingContent) && incomingContent.length > 0 ? { content: incomingContent } : {}),
539
+ ...(resolvedParent !== undefined ? { parent: resolvedParent } : {}),
540
+ });
541
+ if (isRunRoot) {
542
+ rootIds.push(id);
543
+ }
544
+ }
545
+ return rootIds;
546
+ };
547
+ // Pre-order DFS: push self FIRST, then recurse each child, so the flat array
548
+ // is DFS-contiguous. `content` is filled as each child is visited and returns
549
+ // its id; it is omitted for leaves.
550
+ const visit = (node, parent) => {
551
+ assertNotPreFlat(node);
552
+ const id = claimId(node.id);
406
553
  const childIds = [];
407
554
  const flatNode = {
408
555
  id,
@@ -415,7 +562,7 @@ function flattenTree(spec, options = {}) {
415
562
  };
416
563
  flat.push(flatNode);
417
564
  for (const child of node.children ?? []) {
418
- childIds.push(visit(child, id));
565
+ childIds.push(...(isRunSpec(child) ? visitRun(child, id) : [visit(child, id)]));
419
566
  }
420
567
  // Only wire `content` when there are children — leaves stay clean.
421
568
  if (childIds.length > 0) {
@@ -425,7 +572,12 @@ function flattenTree(spec, options = {}) {
425
572
  };
426
573
  const roots = Array.isArray(spec) ? spec : [spec];
427
574
  for (const root of roots) {
428
- visit(root, rootParent);
575
+ if (isRunSpec(root)) {
576
+ visitRun(root, rootParent);
577
+ }
578
+ else {
579
+ visit(root, rootParent);
580
+ }
429
581
  }
430
582
  return flat;
431
583
  }
@@ -1746,21 +1898,46 @@ function deepEqual(a, b) {
1746
1898
  * - emptiness, to decide whether a document carries any user content
1747
1899
  * (placeholder states, "save" button gating, skipping empty submissions).
1748
1900
  *
1749
- * Both accept the loose wire shape (`data: null`, `id: null`, nullish
1750
- * documents) so backend DTOs can be passed as-is.
1901
+ * Both accept the loose wire shape (`data: null`, `id: null`, `parent: null`,
1902
+ * `content: null`, nullish documents) so backend DTOs can be passed as-is.
1751
1903
  */
1752
1904
  /**
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
1905
+ * Normalizes one block from the loose wire shape into the strict saved shape.
1906
+ * The single definition of how the wire spells "absent": `null`/`''` ids and
1907
+ * parents, `null`/`[]` content, `null`/missing data. Shared by
1908
+ * {@link normalizeOutputBlocks} and {@link equalsOutputBlock} so a DTO and its
1909
+ * saved echo are judged under the same rules.
1910
+ * @param block - a block in either the strict or the loose wire shape
1911
+ * @returns the block in the strict saved shape
1912
+ */
1913
+ function normalizeOutputBlock(block) {
1914
+ const { id, data, parent, content, ...rest } = block;
1915
+ return {
1916
+ ...rest,
1917
+ ...(typeof id === 'string' && id !== '' ? { id } : {}),
1918
+ ...(typeof parent === 'string' && parent !== '' ? { parent } : {}),
1919
+ ...(Array.isArray(content) && content.length > 0 ? { content } : {}),
1920
+ data: data ?? {},
1921
+ };
1922
+ }
1923
+ /**
1924
+ * Compares two blocks structurally. Both sides are normalized first
1925
+ * ({@link normalizeOutputBlock}), so the loose wire spellings of "absent"
1926
+ * (`parent: null`, `content: null`, `data: null`) equal the saved shape that
1927
+ * omits them. The `id` participates only when BOTH blocks carry one: the editor
1928
+ * mints a fresh id whenever content arrives without one, so an id-less origin
1756
1929
  * document must still compare equal to its saved echo.
1930
+ *
1931
+ * Edit metadata (`lastEditedAt`/`lastEditedBy`) never participates: it records
1932
+ * WHO touched the block and WHEN, not what it says, so a document whose only
1933
+ * delta is a stamp is not a content change.
1757
1934
  * @param a - first block to compare
1758
1935
  * @param b - second block to compare
1759
1936
  * @returns true when the blocks are structurally equal
1760
1937
  */
1761
1938
  function equalsOutputBlock(a, b) {
1762
- const { id: idA, ...restA } = a;
1763
- const { id: idB, ...restB } = b;
1939
+ const { id: idA, lastEditedAt: _editedAtA, lastEditedBy: _editedByA, ...restA } = normalizeOutputBlock(a);
1940
+ const { id: idB, lastEditedAt: _editedAtB, lastEditedBy: _editedByB, ...restB } = normalizeOutputBlock(b);
1764
1941
  const hasIdA = typeof idA === 'string' && idA !== '';
1765
1942
  const hasIdB = typeof idB === 'string' && idB !== '';
1766
1943
  if (hasIdA && hasIdB && idA !== idB) {
@@ -1803,7 +1980,9 @@ function comparableBlocks(data, options) {
1803
1980
  * document round-tripped through `save()` compares equal to its echo. Block
1804
1981
  * ids are compared only when both sides carry one — the editor mints fresh
1805
1982
  * ids for id-less content, so a legacy document still equals its saved echo.
1806
- * Nullish documents compare equal to `{ blocks: [] }`.
1983
+ * Edit metadata (`lastEditedAt`/`lastEditedBy`) is ignored for the same reason:
1984
+ * it records who touched a block and when, not what it says. Nullish documents
1985
+ * compare equal to `{ blocks: [] }`.
1807
1986
  *
1808
1987
  * Pass `{ ignoreEmptyDefaultBlocks: true }` for dirty-vs-baseline checks so a
1809
1988
  * pristine editor (one empty default paragraph) equals a saved-empty baseline —
@@ -1877,33 +2056,30 @@ function toRenderableData(data) {
1877
2056
  /**
1878
2057
  * Normalizes blocks from the loose wire shape into the strict saved shape at
1879
2058
  * 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).
2059
+ * `null`/empty `id` is dropped so the block factory generates a fresh one, and
2060
+ * nullish/empty hierarchy references (`parent`, `content`) are dropped so a
2061
+ * root-level, childless block is spelled the one way the editor and `save()`
2062
+ * both spell it — absent. Idempotent — strict blocks pass through unchanged
2063
+ * (shallow-copied).
1882
2064
  * @param blocks - blocks in either the strict or the loose wire shape
1883
2065
  * @returns blocks in the strict saved shape
1884
2066
  */
1885
2067
  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
- });
2068
+ return blocks.map(normalizeOutputBlock);
1894
2069
  }
1895
2070
  /**
1896
2071
  * Normalizes a whole loose wire document into the strict saved
1897
2072
  * {@link OutputData} shape at an input boundary. A nullish document becomes
1898
2073
  * `{ blocks: [] }`; `null` envelope fields (`time`/`version`) are dropped; each
1899
2074
  * block is passed through {@link normalizeOutputBlocks}, so `null`/missing
1900
- * `data` becomes `{}` and `null`/empty ids are dropped for regeneration.
2075
+ * `data` becomes `{}`, `null`/empty ids are dropped for regeneration and
2076
+ * nullish/empty `parent`/`content` references are dropped as absent.
1901
2077
  *
1902
2078
  * 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).
2079
+ * passthrough field — `tunes`, real `parent`/`content` references, `indent`,
2080
+ * edit metadata — so a backend DTO can be turned into strict `OutputData`
2081
+ * without silently losing hierarchy or tunes. Idempotent: a strict document
2082
+ * passes through unchanged (shallow-copied).
1907
2083
  * @param data - a document in the strict or loose wire shape, or nullish
1908
2084
  * @returns the document in the strict saved shape
1909
2085
  */
@@ -1993,6 +2169,11 @@ const DATA_ATTR = {
1993
2169
  elementContent: 'data-blok-element-content',
1994
2170
  /** Editor wrapper container */
1995
2171
  editor: 'data-blok-editor',
2172
+ /** Per-instance discriminator on the editor wrapper (a monotonic counter, as a
2173
+ * string). Two editors on one page share every other scope attribute, so this
2174
+ * is what lets a page-level stylesheet — Blok's own injected `style.fontSize`
2175
+ * sheet, or a host rule — address ONE editor. Public styling hook. */
2176
+ instance: 'data-blok-instance',
1996
2177
  /** Redactor zone */
1997
2178
  redactor: 'data-blok-redactor',
1998
2179
  /** Present on the editor wrapper once a `blocks.render()` batch has finished
@@ -2206,6 +2387,24 @@ const DATA_ATTR = {
2206
2387
  /** Active placeholder text */
2207
2388
  placeholderActive: 'data-blok-placeholder-active',
2208
2389
  // ============================================
2390
+ // Columns Layout
2391
+ // ============================================
2392
+ /** The columns row rendered by the column_list tool (the flex container).
2393
+ * Public styling hook — its direct `[data-blok-element]` children are the
2394
+ * column holders, whose shrink floor reads `--blok-column-min-width` and
2395
+ * whose gutter reads `--blok-column-gutter`. */
2396
+ columns: 'data-blok-columns',
2397
+ /** A single column inside a columns row. */
2398
+ column: 'data-blok-column',
2399
+ /** Drag-to-resize separator between two adjacent columns. Present only in
2400
+ * edit mode — the separators ARE the gutter there. */
2401
+ columnResizer: 'data-blok-column-resizer',
2402
+ /** Present on a columns row whose gutter comes from the container's own
2403
+ * column-gap instead of from resizer elements. Set in read-only mode, where
2404
+ * no resizers are built — the discriminator between an editable row and a
2405
+ * published one. */
2406
+ columnsStaticGutter: 'data-blok-columns-static-gutter',
2407
+ // ============================================
2209
2408
  // Nested Blocks
2210
2409
  // ============================================
2211
2410
  /** Container that hosts nested block holders (table cells, toggle/callout/header children).
@@ -2219,6 +2418,23 @@ const DATA_ATTR = {
2219
2418
  /** Element excluded from mutation tracking */
2220
2419
  mutationFree: 'data-blok-mutation-free',
2221
2420
  // ============================================
2421
+ // Keyboard Ownership
2422
+ // ============================================
2423
+ /** Marks a subtree whose keyboard belongs to the Tool that rendered it, not to
2424
+ * the editor. Blok's block-level keydown/keyup handling stands down entirely
2425
+ * for events originating inside it — Escape, Tab, the arrows, "/" and the
2426
+ * Enter/Backspace/Delete structural keys all reach the element untouched.
2427
+ *
2428
+ * Blok already exempts native `<input>`/`<textarea>` from the STRUCTURAL keys
2429
+ * (Enter/Backspace/Delete and "/") because those are contenteditable-shaped
2430
+ * and would splice the document around a form field. Escape/Tab/arrows are
2431
+ * deliberately NOT exempt — they are how a user leaves a field. That default
2432
+ * is right for a one-line title input and wrong for a field with its own
2433
+ * keyboard semantics (Tab between sub-fields, Escape to cancel an edit, arrows
2434
+ * to walk a suggestion list), which is exactly what this attribute is for.
2435
+ * Public authoring hook. */
2436
+ keyboardOwner: 'data-blok-keyboard-owner',
2437
+ // ============================================
2222
2438
  // Navigation
2223
2439
  // ============================================
2224
2440
  /** Block has navigation focus */
@@ -2312,31 +2528,208 @@ const createSelector = (attr, value) => {
2312
2528
  return `[${attr}="${value}"]`;
2313
2529
  };
2314
2530
 
2531
+ /**
2532
+ * HTML manipulation utilities
2533
+ */
2534
+ /**
2535
+ * Strips fake background wrapper elements from HTML content.
2536
+ * These elements are used by the inline toolbar for visual selection highlighting
2537
+ * and should not be persisted in saved data.
2538
+ * @param html - HTML content that may contain fake background elements
2539
+ * @returns HTML content with fake background wrappers removed but their content preserved
2540
+ */
2541
+ const stripFakeBackgroundElements = (html) => {
2542
+ if (!html || !html.includes('data-blok-fake-background')) {
2543
+ return html;
2544
+ }
2545
+ const tempDiv = document.createElement('div');
2546
+ tempDiv.innerHTML = html;
2547
+ const fakeBackgrounds = tempDiv.querySelectorAll('[data-blok-fake-background="true"]');
2548
+ fakeBackgrounds.forEach((element) => {
2549
+ const parent = element.parentNode;
2550
+ if (!parent) {
2551
+ return;
2552
+ }
2553
+ while (element.firstChild) {
2554
+ parent.insertBefore(element.firstChild, element);
2555
+ }
2556
+ parent.removeChild(element);
2557
+ });
2558
+ return tempDiv.innerHTML;
2559
+ };
2560
+ /**
2561
+ * Repositions an already-attached node within `parent`, inserting it before
2562
+ * `reference` (or at the end when `reference` is null), while preserving the
2563
+ * node's live state — an iframe keeps its loaded document, media keeps playing,
2564
+ * form fields keep focus and value.
2565
+ *
2566
+ * A plain insertBefore/appendChild detaches and re-attaches the node, which
2567
+ * resets exactly that state (re-parented iframes reload). The browser's
2568
+ * state-preserving move (`moveBefore`, Chrome 133+) avoids the detach; we fall
2569
+ * back to insertBefore where it is unavailable or throws (e.g. cross-document
2570
+ * moves, or a node that is not connected).
2571
+ */
2572
+ const statefulMove = (parent, node, reference) => {
2573
+ if (reference === node) {
2574
+ return;
2575
+ }
2576
+ if (typeof parent.moveBefore === 'function' && node.isConnected && parent.isConnected) {
2577
+ try {
2578
+ parent.moveBefore(node, reference);
2579
+ return;
2580
+ }
2581
+ catch {
2582
+ // moveBefore enforces stricter invariants than insertBefore (same
2583
+ // document, connected node); fall back to a plain, state-resetting insert.
2584
+ }
2585
+ }
2586
+ parent.insertBefore(node, reference);
2587
+ };
2588
+ /**
2589
+ * Moves `node` to sit immediately before `reference`, preserving live state.
2590
+ * @see statefulMove
2591
+ */
2592
+ const moveElementBefore = (node, reference) => {
2593
+ const parent = reference.parentNode;
2594
+ if (parent instanceof Element) {
2595
+ statefulMove(parent, node, reference);
2596
+ }
2597
+ };
2598
+ /**
2599
+ * Moves `node` to sit immediately after `reference`, preserving live state.
2600
+ * @see statefulMove
2601
+ */
2602
+ const moveElementAfter = (node, reference) => {
2603
+ const parent = reference.parentNode;
2604
+ if (parent instanceof Element) {
2605
+ statefulMove(parent, node, reference.nextSibling);
2606
+ }
2607
+ };
2608
+ /**
2609
+ * Moves `node` to the end of `parent`'s children, preserving live state.
2610
+ * @see statefulMove
2611
+ */
2612
+ const moveElementToEnd = (parent, node) => {
2613
+ statefulMove(parent, node, null);
2614
+ };
2615
+
2616
+ /**
2617
+ * Captures focus + caret, but only the part of it that lives inside `holder` —
2618
+ * a selection anywhere else is none of this move's business and must be left
2619
+ * exactly as it is.
2620
+ * @param holder - the holder about to be moved
2621
+ * @returns the snapshot, or null when the caret is elsewhere
2622
+ */
2623
+ const captureCaretWithin = (holder) => {
2624
+ const ownerDocument = holder.ownerDocument;
2625
+ const activeElement = ownerDocument.activeElement;
2626
+ const focused = activeElement instanceof HTMLElement && holder.contains(activeElement) ? activeElement : null;
2627
+ const selection = ownerDocument.defaultView?.getSelection() ?? null;
2628
+ const range = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2629
+ const rangeInside = range !== null && holder.contains(range.startContainer) && holder.contains(range.endContainer);
2630
+ if (focused === null && !rangeInside) {
2631
+ return null;
2632
+ }
2633
+ return {
2634
+ focused,
2635
+ start: rangeInside && range !== null ? [range.startContainer, range.startOffset] : null,
2636
+ end: rangeInside && range !== null ? [range.endContainer, range.endOffset] : null,
2637
+ };
2638
+ };
2639
+ /**
2640
+ * Re-applies a snapshot, but only where the move actually destroyed it —
2641
+ * a browser with the state-preserving move keeps focus and the caret itself,
2642
+ * and re-setting an identical range there would emit a spurious
2643
+ * `selectionchange` at every container render.
2644
+ * @param snapshot - what {@link captureCaretWithin} read, or null
2645
+ */
2646
+ const restoreCaret = (snapshot) => {
2647
+ if (snapshot === null) {
2648
+ return;
2649
+ }
2650
+ const { focused, start, end } = snapshot;
2651
+ if (focused !== null && focused.isConnected && focused.ownerDocument.activeElement !== focused) {
2652
+ focused.focus({ preventScroll: true });
2653
+ }
2654
+ if (start === null || end === null || !start[0].isConnected) {
2655
+ return;
2656
+ }
2657
+ const ownerDocument = start[0].ownerDocument;
2658
+ const selection = ownerDocument?.defaultView?.getSelection() ?? null;
2659
+ if (ownerDocument === null || selection === null) {
2660
+ return;
2661
+ }
2662
+ const live = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2663
+ const intact = live !== null &&
2664
+ live.startContainer === start[0] &&
2665
+ live.startOffset === start[1] &&
2666
+ live.endContainer === end[0] &&
2667
+ live.endOffset === end[1];
2668
+ if (intact) {
2669
+ return;
2670
+ }
2671
+ const range = ownerDocument.createRange();
2672
+ range.setStart(start[0], start[1]);
2673
+ range.setEnd(end[0], end[1]);
2674
+ selection.removeAllRanges();
2675
+ selection.addRange(range);
2676
+ };
2315
2677
  /**
2316
2678
  * Mount child block holders into a container, skipping children that are
2317
2679
  * already in place or claimed by another nested-blocks container.
2318
2680
  *
2319
- * Used by toggle, header, column, and callout tools to reconcile child holders
2320
- * during the `rendered()` lifecycle hook.
2681
+ * Used by toggle, header, column, and callout tools — and by the React, Vue and
2682
+ * Angular block adapters — to reconcile child holders during the `rendered()`
2683
+ * lifecycle hook. It is the standing DOM↔model reconciler for container blocks:
2684
+ * safe to call on every render, and the only thing that heals a child holder
2685
+ * that core parked outside the container while the container's own slot had not
2686
+ * been created yet.
2687
+ * @param container - the container element child holders belong in; usually the
2688
+ * element carrying `data-blok-nested-blocks`.
2689
+ * @param children - the container block's MODEL children, in model order (e.g.
2690
+ * `api.blocks.getChildren(blockId)`).
2321
2691
  */
2322
2692
  const mountChildBlocks = (container, children) => {
2323
- for (const child of children) {
2693
+ /**
2694
+ * Places a holder at its MODEL position: before the first later child that is
2695
+ * already mounted here, or at the end when there is none. A plain appendChild
2696
+ * would put a reclaimed middle child last — children [A, B, C] with only B
2697
+ * stranded would render A, C, B.
2698
+ *
2699
+ * The move goes through the state-preserving helpers rather than raw
2700
+ * insertBefore/appendChild: re-parenting a live node runs the DOM removing
2701
+ * steps, which blur the focused contenteditable and relocate every live Range
2702
+ * out of the moved subtree — the user's caret vanishes to <body> mid-typing.
2703
+ * Where the browser has no state-preserving move, the caret is put back by
2704
+ * hand around the move.
2705
+ * @param holder - the holder to mount
2706
+ * @param index - the holder's position in `children`
2707
+ */
2708
+ const mountAtModelPosition = (holder, index) => {
2709
+ const anchor = children
2710
+ .slice(index + 1)
2711
+ .find(next => next.holder.parentElement === container)?.holder ?? null;
2712
+ const caret = captureCaretWithin(holder);
2713
+ anchor !== null ? moveElementBefore(holder, anchor) : moveElementToEnd(container, holder);
2714
+ restoreCaret(caret);
2715
+ };
2716
+ for (const [index, child] of children.entries()) {
2324
2717
  if (child.holder.parentElement === container) {
2325
2718
  continue;
2326
2719
  }
2327
2720
  const nested = child.holder.closest(`[${DATA_ATTR.nestedBlocks}]`);
2328
- // Claim a holder stranded in the column_list ROW. A drag reparent into a
2721
+ // Claim a holder stranded in an ANCESTOR nested container. Callers pass
2722
+ // this container's own MODEL children, and a container that ENCLOSES this
2723
+ // one can never be a legitimate home for them — that is model/DOM
2724
+ // divergence by definition. Two ways to get there: a drag reparent into a
2329
2725
  // column drops the moved holder directly into the [data-blok-columns] row
2330
- // (itself a nested-blocks container that wraps each column's own container).
2331
- // Left there it renders as a rogue new column, so pull it down into THIS
2332
- // column container where it belongs. Scoped to the column_list row
2333
- // specifically — claiming from arbitrary ancestor containers would let one
2334
- // table cell steal a paragraph transiently parked in an outer container.
2335
- if (nested !== null &&
2336
- nested !== container &&
2337
- nested.hasAttribute('data-blok-columns') &&
2338
- nested.contains(container)) {
2339
- container.appendChild(child.holder);
2726
+ // (where it renders as a rogue extra column), and a newly inserted first
2727
+ // child is anchored as the container block's DOM *sibling*, i.e. inside
2728
+ // whatever nested container encloses it — permanent when the destination
2729
+ // slot had not committed yet (a framework adapter's portal commits a render
2730
+ // later than core inserts), because nothing else ever re-runs the mount.
2731
+ if (nested !== null && nested !== container && nested.contains(container)) {
2732
+ mountAtModelPosition(child.holder, index);
2340
2733
  continue;
2341
2734
  }
2342
2735
  // Otherwise leave holders that already live inside another nested container
@@ -2344,10 +2737,26 @@ const mountChildBlocks = (container, children) => {
2344
2737
  if (nested !== null) {
2345
2738
  continue;
2346
2739
  }
2347
- container.appendChild(child.holder);
2740
+ mountAtModelPosition(child.holder, index);
2348
2741
  }
2349
2742
  };
2350
2743
 
2744
+ /**
2745
+ * Fired when a CONTAINER block's child holders have been mounted into its
2746
+ * child slot — the moment the child DOM has settled and a caret set into a
2747
+ * freshly inserted child sticks.
2748
+ *
2749
+ * Emitted by the React, Vue and Angular block adapters, whose portals commit a
2750
+ * frame AFTER core's `rendered()` hook; a vanilla container tool mounts its
2751
+ * children synchronously inside `rendered()`, so for those `block:rendered`
2752
+ * already means "settled". It fires on every reconciliation pass of the slot,
2753
+ * not only the first, so treat it as a settle signal rather than a change
2754
+ * signal.
2755
+ *
2756
+ * Public event name: `block:childrenMounted`.
2757
+ */
2758
+ const BlockChildrenMounted = 'block:childrenMounted';
2759
+
2351
2760
  /**
2352
2761
  * The blessed all-in-one Angular component for embedding Blok (mirrors React's
2353
2762
  * `BlokEditor`). Delegates instance lifecycle to an internal `BlokContentDirective`
@@ -2361,8 +2770,8 @@ const mountChildBlocks = (container, children) => {
2361
2770
  * `styleTokens` through `editor.tokens.set()` (replace semantics),
2362
2771
  * `i18n` through `editor.i18n.update()` (deep-equal–deduped; seeded at
2363
2772
  * construction so the locale resolves during boot) and `data`
2364
- * through `editor.render()` (deep-equal–deduped); everything else seeds
2365
- * construction.
2773
+ * through `editor.render()` (content-deduped via `equalsOutputData`); everything
2774
+ * else seeds construction.
2366
2775
  *
2367
2776
  * Implementation note: classic `@Input()`/`@ViewChild()` decorators are used
2368
2777
  * (not signal `input()`/`viewChild()`) for JIT compatibility — see
@@ -2495,9 +2904,12 @@ class BlokEditorComponent {
2495
2904
  /** Changing this input's identity destroys and recreates the editor (≙ React `deps`). */
2496
2905
  recreateKey;
2497
2906
  /**
2498
- * Content the editor currently reflects. Set when seeded or rendered (the data
2499
- * effect below). A controlled `data` echo that deep-equals this baseline is a
2500
- * no-op, so it won't clobber the caret. Renders are serialized via `renderChain`.
2907
+ * Content the editor currently reflects. Set when seeded, when the data effect
2908
+ * renders, when the editor emits its own output (`coreOnSave`) and when the
2909
+ * imperative `render()` facade lands. A controlled `data` echo that
2910
+ * content-equals this baseline is a no-op, so it won't clobber the caret.
2911
+ * Renders are serialized via `renderChain`. `undefined` means "nothing recorded
2912
+ * yet" — never an empty document.
2501
2913
  */
2502
2914
  lastRenderedData;
2503
2915
  /** Last token set pushed through `tokens.set`, for deep-equal deduping. */
@@ -2519,16 +2931,91 @@ class BlokEditorComponent {
2519
2931
  * it straight back into `data` deep-equals the baseline and is deduped to a
2520
2932
  * no-op (no redundant render, no caret reset). Re-enters the Angular zone since
2521
2933
  * the editor runs outside it.
2934
+ *
2935
+ * Falls back to an escape-hatch `[config]` callback when nothing Angular-side
2936
+ * consumes the save — the baseline is recorded either way, so the controlled
2937
+ * `data` dedupe works on both paths.
2522
2938
  */
2523
- coreOnSave = (data) => {
2939
+ coreOnSave = (data, api) => {
2524
2940
  this.lastRenderedData = data;
2525
- this.ngZone.run(() => {
2526
- this.dataChange.emit(data);
2527
- this.save.emit(data);
2528
- this.cvaOnChange?.(data);
2529
- this.cvaOnTouched?.();
2530
- });
2941
+ if (this.hasSaveConsumer()) {
2942
+ this.ngZone.run(() => {
2943
+ this.dataChange.emit(data);
2944
+ this.save.emit(data);
2945
+ this.cvaOnChange?.(data);
2946
+ this.cvaOnTouched?.();
2947
+ });
2948
+ return;
2949
+ }
2950
+ this.escapeHatchConfig().onSave?.(data, api);
2951
+ };
2952
+ /**
2953
+ * Stable wrappers for the remaining live callbacks. Each resolves its target
2954
+ * at call time (an observed output, else the `[config]` escape hatch), so a
2955
+ * pushed handler never goes stale and only PRESENCE has to be synced.
2956
+ */
2957
+ coreOnChange = (api, event) => {
2958
+ if (this.change.observed) {
2959
+ this.ngZone.run(() => this.change.emit({ api, event }));
2960
+ return;
2961
+ }
2962
+ this.escapeHatchConfig().onChange?.(api, event);
2531
2963
  };
2964
+ coreOnAfterRender = (api) => {
2965
+ if (this.afterRender.observed) {
2966
+ this.ngZone.run(() => this.afterRender.emit(api));
2967
+ return;
2968
+ }
2969
+ this.escapeHatchConfig().onAfterRender?.(api);
2970
+ };
2971
+ /*
2972
+ * Transforms run synchronously inside core's render pipeline and must return a
2973
+ * value — never wrap them in ngZone.run.
2974
+ */
2975
+ coreOnBeforeRender = (blocks) => {
2976
+ const transform = this.onBeforeRender ?? this.escapeHatchConfig().onBeforeRender;
2977
+ return transform?.(blocks) ?? blocks;
2978
+ };
2979
+ coreOnEnter = (event, api) => this.escapeHatchConfig().onEnter?.(event, api);
2980
+ coreOnSubmit = (data, api) => {
2981
+ this.escapeHatchConfig().onSubmit?.(data, api);
2982
+ };
2983
+ /** provideBlok defaults merged under the `[config]` escape hatch. */
2984
+ escapeHatchConfig() {
2985
+ return { ...this.defaults, ...this.config };
2986
+ }
2987
+ /** True when an output, a two-way binding or Angular forms consumes `onSave`. */
2988
+ hasSaveConsumer() {
2989
+ return this.dataChange.observed || this.save.observed || this.cvaOnChange !== undefined;
2990
+ }
2991
+ /**
2992
+ * The live callbacks this component currently wires into core, keyed by
2993
+ * handler name — `undefined` for the ones nothing consumes.
2994
+ *
2995
+ * PRESENCE is the semantics in core (an `onSubmit` turns Enter into
2996
+ * serialize-and-submit, an `onSave` arms the change-observation pipeline), so
2997
+ * an unconsumed callback must stay absent. One source of truth for both the
2998
+ * construction config and the runtime sync in `ngDoCheck`, so the two cannot
2999
+ * drift.
3000
+ * @returns the wrappers to install, `undefined` where nothing is wired
3001
+ */
3002
+ liveHandlers() {
3003
+ const cfg = this.escapeHatchConfig();
3004
+ return {
3005
+ onChange: this.change.observed || cfg.onChange !== undefined ? this.coreOnChange : undefined,
3006
+ onSave: this.hasSaveConsumer() || cfg.onSave !== undefined ? this.coreOnSave : undefined,
3007
+ onEnter: cfg.onEnter !== undefined ? this.coreOnEnter : undefined,
3008
+ onSubmit: cfg.onSubmit !== undefined ? this.coreOnSubmit : undefined,
3009
+ onBeforeRender: this.onBeforeRender !== undefined || cfg.onBeforeRender !== undefined
3010
+ ? this.coreOnBeforeRender
3011
+ : undefined,
3012
+ onAfterRender: this.afterRender.observed || cfg.onAfterRender !== undefined
3013
+ ? this.coreOnAfterRender
3014
+ : undefined,
3015
+ };
3016
+ }
3017
+ /** Handler presence installed on the live editor (the sync dedupe baseline). */
3018
+ appliedHandlers = {};
2532
3019
  /** Live Blok instance, or null until `isReady` resolves / after destroy. */
2533
3020
  instance = computed(() => this.content()?.instance() ?? null, /* @ts-ignore */
2534
3021
  ...(ngDevMode ? [{ debugName: "instance" }] : /* istanbul ignore next */ []));
@@ -2589,27 +3076,24 @@ class BlokEditorComponent {
2589
3076
  if (i18n !== undefined) {
2590
3077
  cfg.i18n = i18n;
2591
3078
  }
2592
- // Opt-in: only attach each core callback when the consumer actually consumes
2593
- // it (an observed output, a registered forms hook, or a provided transform).
2594
- // Their mere presence makes the core serialize / run hooks on every change.
2595
- if (this.dataChange.observed || this.save.observed || this.cvaOnChange !== undefined) {
2596
- cfg.onSave = this.coreOnSave;
2597
- }
2598
- if (this.change.observed) {
2599
- cfg.onChange = (api, event) => this.ngZone.run(() => this.change.emit({ api, event }));
2600
- }
2601
- if (this.afterRender.observed) {
2602
- cfg.onAfterRender = (api) => this.ngZone.run(() => this.afterRender.emit(api));
2603
- }
3079
+ // Opt-in: only attach each live callback when the consumer actually consumes
3080
+ // it (an observed output, a registered forms hook, a provided transform or an
3081
+ // escape-hatch config callback). Their mere presence makes the core serialize
3082
+ // / run hooks on every change. The same map drives the runtime sync in
3083
+ // `ngDoCheck`, so construction and post-mount can never disagree.
3084
+ const handlers = this.liveHandlers();
3085
+ cfg.onChange = handlers.onChange;
3086
+ cfg.onSave = handlers.onSave;
3087
+ cfg.onEnter = handlers.onEnter;
3088
+ cfg.onSubmit = handlers.onSubmit;
3089
+ cfg.onBeforeRender = handlers.onBeforeRender;
3090
+ cfg.onAfterRender = handlers.onAfterRender;
3091
+ this.appliedHandlers = handlers;
2604
3092
  if (this.themeChange.observed) {
2605
3093
  cfg.onThemeChange = (resolved) => this.ngZone.run(() => this.themeChange.emit(resolved));
2606
3094
  }
2607
- // Transforms run synchronously inside core's render/paste pipeline and must
2608
- // return a value — never wrap them in ngZone.run.
2609
- const beforeRender = this.onBeforeRender;
2610
- if (beforeRender !== undefined) {
2611
- cfg.onBeforeRender = (blocks) => beforeRender(blocks);
2612
- }
3095
+ // Transforms run synchronously inside core's paste pipeline and must return a
3096
+ // value — never wrap them in ngZone.run.
2613
3097
  const beforePaste = this.onBeforePaste;
2614
3098
  if (beforePaste !== undefined) {
2615
3099
  cfg.onBeforePaste = (html) => beforePaste(html);
@@ -2623,6 +3107,62 @@ class BlokEditorComponent {
2623
3107
  ngAfterViewInit() {
2624
3108
  this.content.set(this.contentQuery ?? null);
2625
3109
  }
3110
+ /**
3111
+ * Reactive callback presence.
3112
+ *
3113
+ * Callback wiring was decided once, at construction, and in core the presence
3114
+ * of a handler IS the semantics: an `onSubmit` makes Enter serialize-and-submit
3115
+ * instead of splitting the block, an `onSave` arms the whole change-observation
3116
+ * pipeline. A `[config]` swap, an `*ngIf`-gated `(save)` output or a
3117
+ * `registerOnChange` from Angular forms arriving after mount therefore needed a
3118
+ * `recreateKey` bump — destroying the editor and losing caret and undo history.
3119
+ *
3120
+ * Diff the wired handlers against what is installed and push genuine flips
3121
+ * through the runtime `handlers.set` API, writing `undefined` for a handler
3122
+ * that lost its consumer so the change stays reversible. `ngDoCheck` (not an
3123
+ * `effect`) because `EventEmitter.observed` and the plain `@Input` transforms
3124
+ * are not signals; the comparison is six identity checks against stable
3125
+ * wrappers, so an unchanged cycle pushes nothing.
3126
+ */
3127
+ ngDoCheck() {
3128
+ const editor = this.instance();
3129
+ if (editor === null) {
3130
+ return;
3131
+ }
3132
+ const desired = this.liveHandlers();
3133
+ const applied = this.appliedHandlers;
3134
+ const diff = {};
3135
+ const changed = { value: false };
3136
+ const sync = (key, write) => {
3137
+ if (desired[key] === applied[key]) {
3138
+ return;
3139
+ }
3140
+ applied[key] = desired[key];
3141
+ changed.value = true;
3142
+ write(desired[key]);
3143
+ };
3144
+ sync('onChange', (value) => {
3145
+ diff.onChange = value;
3146
+ });
3147
+ sync('onSave', (value) => {
3148
+ diff.onSave = value;
3149
+ });
3150
+ sync('onEnter', (value) => {
3151
+ diff.onEnter = value;
3152
+ });
3153
+ sync('onSubmit', (value) => {
3154
+ diff.onSubmit = value;
3155
+ });
3156
+ sync('onBeforeRender', (value) => {
3157
+ diff.onBeforeRender = value;
3158
+ });
3159
+ sync('onAfterRender', (value) => {
3160
+ diff.onAfterRender = value;
3161
+ });
3162
+ if (changed.value) {
3163
+ editor.handlers.set(diff);
3164
+ }
3165
+ }
2626
3166
  // ---- Curated imperative facade (delegates to the live instance; no-ops until ready) ----
2627
3167
  /** Serialize the current content. Resolves undefined until the editor is ready. */
2628
3168
  save$() {
@@ -2632,9 +3172,24 @@ class BlokEditorComponent {
2632
3172
  focus(atEnd) {
2633
3173
  this.instance()?.focus(atEnd);
2634
3174
  }
2635
- /** Replace the editor content. Resolves undefined until the editor is ready. */
3175
+ /**
3176
+ * Replace the editor content. Resolves undefined until the editor is ready.
3177
+ *
3178
+ * Safe to mix with a controlled `[data]` input: the rendered document becomes
3179
+ * the content baseline once the call lands, so a later `[data]` change back to
3180
+ * the previous document still re-renders instead of being deduped against a
3181
+ * baseline the editor no longer reflects. Recorded only on the resolved path —
3182
+ * a failed render leaves the editor on its previous content.
3183
+ * @param data - the document to render
3184
+ */
2636
3185
  render(data) {
2637
- return this.instance()?.render(data);
3186
+ const editor = this.instance();
3187
+ if (editor === null) {
3188
+ return undefined;
3189
+ }
3190
+ return editor.render(data).then(() => {
3191
+ this.lastRenderedData = data;
3192
+ });
2638
3193
  }
2639
3194
  constructor() {
2640
3195
  // Each effect reads `instance()` so it re-applies once the editor appears
@@ -2755,7 +3310,14 @@ class BlokEditorComponent {
2755
3310
  this.lastRenderedData = data;
2756
3311
  return;
2757
3312
  }
2758
- if (deepEqual(data, this.lastRenderedData)) {
3313
+ // Structural comparison (`equalsOutputData`, not a raw deep-equal): a host
3314
+ // that persists the editor's own document and hands back a stripped copy —
3315
+ // fresh `time`, dropped ids, no `lastEditedAt` stamp — is still echoing
3316
+ // content the editor already shows, and re-rendering it would reset the
3317
+ // caret for zero visual change. `undefined` means "nothing recorded yet",
3318
+ // which is NOT an empty document, so it must never match.
3319
+ const baseline = this.lastRenderedData;
3320
+ if (baseline !== undefined && equalsOutputData(data, baseline)) {
2759
3321
  return;
2760
3322
  }
2761
3323
  this.lastRenderedData = data;
@@ -2895,6 +3457,76 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
2895
3457
  }] } });
2896
3458
 
2897
3459
  // packages/angular/src/createAngularBlock.ts
3460
+ /** Statics the generated class owns; an authored `statics` bag can never take them over. */
3461
+ const RESERVED_STATICS = ['toolbox', 'isReadOnlySupported', '__isBlokAngularBlock'];
3462
+ /**
3463
+ * Origins that mean "the author just made this block" — the only ones that fire
3464
+ * {@link CreateAngularBlockSpec.onCreated}. Written as an allow-list so a future
3465
+ * core origin fails CLOSED (no creation signal) instead of silently opting in.
3466
+ *
3467
+ * `undefined` is included because core always supplies an origin: an absent one
3468
+ * means a host hand-built the constructor options, which is an explicit
3469
+ * creation — the same reading core's own container tools apply.
3470
+ *
3471
+ * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
3472
+ * alone would refuse to seed for `api.blocks.insert('steps')` and for a
3473
+ * turn-into, leaving an empty, unusable container.
3474
+ */
3475
+ const CREATION_ORIGINS = new Set([
3476
+ undefined,
3477
+ 'user',
3478
+ 'api',
3479
+ 'convert',
3480
+ ]);
3481
+ /**
3482
+ * Apply one pass of per-child holder attributes and clear the previous pass's
3483
+ * leftovers — including on a child that has since left the container, which
3484
+ * would otherwise keep a dead index forever.
3485
+ * @param stamped - the container's ledger of what the last pass wrote (mutated)
3486
+ * @param children - the container's model children, in model order
3487
+ * @param decorate - the authored per-child decorator, if any
3488
+ */
3489
+ const applyChildAttributes = (stamped, children, decorate) => {
3490
+ const next = new Map();
3491
+ children.forEach((child, index) => {
3492
+ const names = [];
3493
+ for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
3494
+ if (value === null || value === undefined) {
3495
+ child.holder.removeAttribute(name);
3496
+ continue;
3497
+ }
3498
+ child.holder.setAttribute(name, String(value));
3499
+ names.push(name);
3500
+ }
3501
+ next.set(child.id, { holder: child.holder, names });
3502
+ });
3503
+ for (const [id, previous] of stamped) {
3504
+ const current = next.get(id);
3505
+ previous.names
3506
+ .filter(name => current === undefined || !current.names.includes(name))
3507
+ .forEach(name => previous.holder.removeAttribute(name));
3508
+ }
3509
+ stamped.clear();
3510
+ next.forEach((entry, id) => stamped.set(id, entry));
3511
+ };
3512
+ /**
3513
+ * Announce that a container's child holders have settled in its slot — the
3514
+ * signal a host waits on before putting the caret into a freshly inserted
3515
+ * child.
3516
+ *
3517
+ * Optional-chained: the editor always supplies `events`, but a host unit test
3518
+ * may hand a block a partial `api`, and an observability signal must never be
3519
+ * able to break a render.
3520
+ * @param api - the editor-level API
3521
+ * @param blockId - the container block's id
3522
+ * @param children - the container's model children, in model order
3523
+ */
3524
+ const emitChildrenMounted = (api, blockId, children) => {
3525
+ api?.events?.emit(BlockChildrenMounted, {
3526
+ blockId,
3527
+ childIds: children.map(child => child.id),
3528
+ });
3529
+ };
2898
3530
  /**
2899
3531
  * Author a first-party Angular block. Returns a `BlockToolConstructable`
2900
3532
  * registered exactly like a vanilla tool (`tools: { type: { class:
@@ -2912,7 +3544,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
2912
3544
  * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
2913
3545
  */
2914
3546
  function createAngularBlock(spec) {
2915
- return class AngularBlockTool {
3547
+ const AngularBlockTool = class AngularBlockTool {
2916
3548
  /** Marker so the directive can detect Angular-block tools and inject the registry. */
2917
3549
  static __isBlokAngularBlock = true;
2918
3550
  static get toolbox() {
@@ -2928,6 +3560,8 @@ function createAngularBlock(spec) {
2928
3560
  return true;
2929
3561
  }
2930
3562
  blockApi;
3563
+ /** The editor-level API, handed to the component as `ctx.api`. */
3564
+ api;
2931
3565
  registry;
2932
3566
  pointerDrag;
2933
3567
  dataSig;
@@ -2939,26 +3573,39 @@ function createAngularBlock(spec) {
2939
3573
  hostEl = null;
2940
3574
  /** Last host passed to ctx.mountChildren, re-mounted on each data change. */
2941
3575
  childHost = null;
3576
+ /** Last per-child decorator passed to ctx.mountChildren; re-applied on each remount. */
3577
+ childAttributes;
3578
+ /** What the last decoration pass wrote, so a dropped key is cleaned up. */
3579
+ stampedChildren = new Map();
3580
+ /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
3581
+ origin;
3582
+ /** True once the post-mount hooks fired; a repeated rendered() must not re-fire them. */
3583
+ mountSignalled = false;
2942
3584
  /** True while a pointer drag suppresses dispatchChange. */
2943
3585
  pendingDispatch = false;
2944
3586
  constructor(options) {
2945
3587
  this.blockApi = options.block;
2946
3588
  const config = (options.config ?? {});
2947
3589
  this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY];
3590
+ this.api = options.api;
2948
3591
  // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
2949
3592
  // (core silently drops a dispatchChange while a drag is active).
2950
- const api = options.api;
2951
- this.pointerDrag = () => api?.blocks?.isPointerDragActive === true;
3593
+ const drag = options.api;
3594
+ this.pointerDrag = () => drag?.blocks?.isPointerDragActive === true;
2952
3595
  this.mirror = fillDefaults(spec.propSchema, (options.data ?? {}));
2953
3596
  this.lastRendered = this.mirror;
2954
3597
  this.dataSig = signal(this.mirror, /* @ts-ignore */
2955
3598
  ...(ngDevMode ? [{ debugName: "dataSig" }] : /* istanbul ignore next */ []));
2956
3599
  this.readOnlySig = signal(options.readOnly, /* @ts-ignore */
2957
3600
  ...(ngDevMode ? [{ debugName: "readOnlySig" }] : /* istanbul ignore next */ []));
3601
+ // Absent origin means a caller that predates the signal; 'api' is core's
3602
+ // own default, so it is never mistaken for a user gesture.
3603
+ this.origin = options.origin ?? 'api';
2958
3604
  this.ctx = {
2959
3605
  data: this.dataSig.asReadonly(),
2960
3606
  commit: this.commit,
2961
3607
  block: this.blockApi,
3608
+ api: this.api,
2962
3609
  readOnly: this.readOnlySig.asReadonly(),
2963
3610
  mountChildren: this.mountChildren,
2964
3611
  };
@@ -2978,6 +3625,25 @@ function createAngularBlock(spec) {
2978
3625
  }
2979
3626
  rendered() {
2980
3627
  spec.onRendered?.(this.blockApi);
3628
+ // register() mounted the component and ran its first change detection
3629
+ // synchronously inside render(), and core has now put the host in the
3630
+ // document — so this is the settled moment. Once per instance: core
3631
+ // re-runs rendered() for a re-materialised block, and a repeated creation
3632
+ // signal would seed a container's default children twice. With no
3633
+ // registry (vanilla core, no directive) nothing was ever mounted, so
3634
+ // there is no settle to report — matching React/Vue, where the signal
3635
+ // originates in the component itself.
3636
+ if (this.registry === undefined || this.mountSignalled) {
3637
+ return;
3638
+ }
3639
+ this.mountSignalled = true;
3640
+ const context = { origin: this.origin, api: this.api };
3641
+ spec.onMounted?.(this.blockApi, context);
3642
+ // Creation-only, and after onMounted: a seeding hook must see whatever
3643
+ // the mount hook already put in place.
3644
+ if (CREATION_ORIGINS.has(this.origin)) {
3645
+ spec.onCreated?.(this.blockApi, context);
3646
+ }
2981
3647
  }
2982
3648
  save() {
2983
3649
  return this.mirror;
@@ -3012,29 +3678,55 @@ function createAngularBlock(spec) {
3012
3678
  this.readOnlySig.set(state);
3013
3679
  this.registry?.flush(this.blockApi.id);
3014
3680
  }
3681
+ /**
3682
+ * Core's toolbar-anchor hook. Always defined so the delegation is one
3683
+ * `typeof … === 'function'` probe away, and always resolved fresh against
3684
+ * the LIVE host — the anchor element is Angular-rendered, so it does not
3685
+ * exist yet when the tool is constructed and may be replaced on any change
3686
+ * detection pass. A spec without a resolver returns undefined, which is
3687
+ * exactly what core's default positioning already assumes.
3688
+ */
3689
+ getToolbarAnchorElement() {
3690
+ const host = this.hostEl;
3691
+ if (host === null || spec.getToolbarAnchorElement === undefined) {
3692
+ return undefined;
3693
+ }
3694
+ return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
3695
+ }
3015
3696
  moved() {
3016
3697
  // No remount: core relocates the host element; the mounted view rides along
3017
3698
  // as its DOM children.
3018
3699
  spec.onMoved?.(this.blockApi);
3019
3700
  }
3020
3701
  removed() {
3021
- this.registry?.unregister(this.blockApi.id);
3702
+ // Ownership-scoped: core composes a REPLACEMENT block (which mounts under
3703
+ // the SAME id) before it tears this one down, so an unqualified unregister
3704
+ // here would destroy the live componentRef and blank the block.
3705
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
3022
3706
  spec.onRemoved?.(this.blockApi);
3023
3707
  }
3024
3708
  destroy() {
3025
3709
  // Idempotent with removed(); unregister is safe when already absent.
3026
- this.registry?.unregister(this.blockApi.id);
3710
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
3027
3711
  }
3028
- /** Container blocks: remember the host and (re)mount the real child holders. */
3029
- mountChildren = (host) => {
3712
+ /**
3713
+ * Container blocks: remember the host (and any per-child decorator) and
3714
+ * (re)mount the real child holders.
3715
+ */
3716
+ mountChildren = (host, childAttributes) => {
3030
3717
  this.childHost = host;
3718
+ this.childAttributes = childAttributes;
3031
3719
  host.setAttribute(DATA_ATTR.nestedBlocks, '');
3032
3720
  this.remountChildren();
3033
3721
  };
3034
3722
  remountChildren() {
3035
- if (this.childHost !== null) {
3036
- mountChildBlocks(this.childHost, this.blockApi.getChildren());
3723
+ if (this.childHost === null) {
3724
+ return;
3037
3725
  }
3726
+ const children = this.blockApi.getChildren();
3727
+ mountChildBlocks(this.childHost, children);
3728
+ applyChildAttributes(this.stampedChildren, children, this.childAttributes);
3729
+ emitChildrenMounted(this.api, this.blockApi.id, children);
3038
3730
  }
3039
3731
  /**
3040
3732
  * The only data write path. Merges the patch into the frozen mirror, swaps
@@ -3082,6 +3774,22 @@ function createAngularBlock(spec) {
3082
3774
  requestAnimationFrame(retry);
3083
3775
  }
3084
3776
  };
3777
+ // Forward the authored statics onto the generated class. `defineProperty`
3778
+ // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors and a
3779
+ // plain write would throw in strict mode; the reserved list keeps a stray bag
3780
+ // from taking those — or the adapter's own marker — over.
3781
+ for (const [key, value] of Object.entries(spec.statics ?? {})) {
3782
+ if (RESERVED_STATICS.includes(key)) {
3783
+ continue;
3784
+ }
3785
+ Object.defineProperty(AngularBlockTool, key, {
3786
+ value,
3787
+ writable: true,
3788
+ enumerable: true,
3789
+ configurable: true,
3790
+ });
3791
+ }
3792
+ return AngularBlockTool;
3085
3793
  }
3086
3794
 
3087
3795
  // packages/angular/src/useBlocks.ts
@@ -3266,5 +3974,5 @@ function injectBlokReady(options = {}) {
3266
3974
  * Generated bundle index. Do not edit.
3267
3975
  */
3268
3976
 
3269
- export { BLOK_BLOCK_CONTEXT, BLOK_DEFAULT_CONFIG, BlokContentDirective, BlokEditorComponent, createAngularBlock, injectBlocks, injectBlokReady, provideBlok };
3977
+ export { BLOK_BLOCK_CONTEXT, BLOK_DEFAULT_CONFIG, BLOK_EDITOR_INSTANCE, BlokContentDirective, BlokEditorComponent, createAngularBlock, injectBlocks, injectBlokInstance, injectBlokReady, provideBlok };
3270
3978
  //# sourceMappingURL=bloklabs-angular.mjs.map