@bloklabs/angular 1.7.0 → 1.8.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
@@ -1746,21 +1807,46 @@ function deepEqual(a, b) {
1746
1807
  * - emptiness, to decide whether a document carries any user content
1747
1808
  * (placeholder states, "save" button gating, skipping empty submissions).
1748
1809
  *
1749
- * Both accept the loose wire shape (`data: null`, `id: null`, nullish
1750
- * documents) so backend DTOs can be passed as-is.
1810
+ * Both accept the loose wire shape (`data: null`, `id: null`, `parent: null`,
1811
+ * `content: null`, nullish documents) so backend DTOs can be passed as-is.
1751
1812
  */
1752
1813
  /**
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
1814
+ * Normalizes one block from the loose wire shape into the strict saved shape.
1815
+ * The single definition of how the wire spells "absent": `null`/`''` ids and
1816
+ * parents, `null`/`[]` content, `null`/missing data. Shared by
1817
+ * {@link normalizeOutputBlocks} and {@link equalsOutputBlock} so a DTO and its
1818
+ * saved echo are judged under the same rules.
1819
+ * @param block - a block in either the strict or the loose wire shape
1820
+ * @returns the block in the strict saved shape
1821
+ */
1822
+ function normalizeOutputBlock(block) {
1823
+ const { id, data, parent, content, ...rest } = block;
1824
+ return {
1825
+ ...rest,
1826
+ ...(typeof id === 'string' && id !== '' ? { id } : {}),
1827
+ ...(typeof parent === 'string' && parent !== '' ? { parent } : {}),
1828
+ ...(Array.isArray(content) && content.length > 0 ? { content } : {}),
1829
+ data: data ?? {},
1830
+ };
1831
+ }
1832
+ /**
1833
+ * Compares two blocks structurally. Both sides are normalized first
1834
+ * ({@link normalizeOutputBlock}), so the loose wire spellings of "absent"
1835
+ * (`parent: null`, `content: null`, `data: null`) equal the saved shape that
1836
+ * omits them. The `id` participates only when BOTH blocks carry one: the editor
1837
+ * mints a fresh id whenever content arrives without one, so an id-less origin
1756
1838
  * document must still compare equal to its saved echo.
1839
+ *
1840
+ * Edit metadata (`lastEditedAt`/`lastEditedBy`) never participates: it records
1841
+ * WHO touched the block and WHEN, not what it says, so a document whose only
1842
+ * delta is a stamp is not a content change.
1757
1843
  * @param a - first block to compare
1758
1844
  * @param b - second block to compare
1759
1845
  * @returns true when the blocks are structurally equal
1760
1846
  */
1761
1847
  function equalsOutputBlock(a, b) {
1762
- const { id: idA, ...restA } = a;
1763
- const { id: idB, ...restB } = b;
1848
+ const { id: idA, lastEditedAt: _editedAtA, lastEditedBy: _editedByA, ...restA } = normalizeOutputBlock(a);
1849
+ const { id: idB, lastEditedAt: _editedAtB, lastEditedBy: _editedByB, ...restB } = normalizeOutputBlock(b);
1764
1850
  const hasIdA = typeof idA === 'string' && idA !== '';
1765
1851
  const hasIdB = typeof idB === 'string' && idB !== '';
1766
1852
  if (hasIdA && hasIdB && idA !== idB) {
@@ -1803,7 +1889,9 @@ function comparableBlocks(data, options) {
1803
1889
  * document round-tripped through `save()` compares equal to its echo. Block
1804
1890
  * ids are compared only when both sides carry one — the editor mints fresh
1805
1891
  * ids for id-less content, so a legacy document still equals its saved echo.
1806
- * Nullish documents compare equal to `{ blocks: [] }`.
1892
+ * Edit metadata (`lastEditedAt`/`lastEditedBy`) is ignored for the same reason:
1893
+ * it records who touched a block and when, not what it says. Nullish documents
1894
+ * compare equal to `{ blocks: [] }`.
1807
1895
  *
1808
1896
  * Pass `{ ignoreEmptyDefaultBlocks: true }` for dirty-vs-baseline checks so a
1809
1897
  * pristine editor (one empty default paragraph) equals a saved-empty baseline —
@@ -1877,33 +1965,30 @@ function toRenderableData(data) {
1877
1965
  /**
1878
1966
  * Normalizes blocks from the loose wire shape into the strict saved shape at
1879
1967
  * 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).
1968
+ * `null`/empty `id` is dropped so the block factory generates a fresh one, and
1969
+ * nullish/empty hierarchy references (`parent`, `content`) are dropped so a
1970
+ * root-level, childless block is spelled the one way the editor and `save()`
1971
+ * both spell it — absent. Idempotent — strict blocks pass through unchanged
1972
+ * (shallow-copied).
1882
1973
  * @param blocks - blocks in either the strict or the loose wire shape
1883
1974
  * @returns blocks in the strict saved shape
1884
1975
  */
1885
1976
  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
- });
1977
+ return blocks.map(normalizeOutputBlock);
1894
1978
  }
1895
1979
  /**
1896
1980
  * Normalizes a whole loose wire document into the strict saved
1897
1981
  * {@link OutputData} shape at an input boundary. A nullish document becomes
1898
1982
  * `{ blocks: [] }`; `null` envelope fields (`time`/`version`) are dropped; each
1899
1983
  * block is passed through {@link normalizeOutputBlocks}, so `null`/missing
1900
- * `data` becomes `{}` and `null`/empty ids are dropped for regeneration.
1984
+ * `data` becomes `{}`, `null`/empty ids are dropped for regeneration and
1985
+ * nullish/empty `parent`/`content` references are dropped as absent.
1901
1986
  *
1902
1987
  * 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).
1988
+ * passthrough field — `tunes`, real `parent`/`content` references, `indent`,
1989
+ * edit metadata — so a backend DTO can be turned into strict `OutputData`
1990
+ * without silently losing hierarchy or tunes. Idempotent: a strict document
1991
+ * passes through unchanged (shallow-copied).
1907
1992
  * @param data - a document in the strict or loose wire shape, or nullish
1908
1993
  * @returns the document in the strict saved shape
1909
1994
  */
@@ -1993,6 +2078,11 @@ const DATA_ATTR = {
1993
2078
  elementContent: 'data-blok-element-content',
1994
2079
  /** Editor wrapper container */
1995
2080
  editor: 'data-blok-editor',
2081
+ /** Per-instance discriminator on the editor wrapper (a monotonic counter, as a
2082
+ * string). Two editors on one page share every other scope attribute, so this
2083
+ * is what lets a page-level stylesheet — Blok's own injected `style.fontSize`
2084
+ * sheet, or a host rule — address ONE editor. Public styling hook. */
2085
+ instance: 'data-blok-instance',
1996
2086
  /** Redactor zone */
1997
2087
  redactor: 'data-blok-redactor',
1998
2088
  /** Present on the editor wrapper once a `blocks.render()` batch has finished
@@ -2206,6 +2296,24 @@ const DATA_ATTR = {
2206
2296
  /** Active placeholder text */
2207
2297
  placeholderActive: 'data-blok-placeholder-active',
2208
2298
  // ============================================
2299
+ // Columns Layout
2300
+ // ============================================
2301
+ /** The columns row rendered by the column_list tool (the flex container).
2302
+ * Public styling hook — its direct `[data-blok-element]` children are the
2303
+ * column holders, whose shrink floor reads `--blok-column-min-width` and
2304
+ * whose gutter reads `--blok-column-gutter`. */
2305
+ columns: 'data-blok-columns',
2306
+ /** A single column inside a columns row. */
2307
+ column: 'data-blok-column',
2308
+ /** Drag-to-resize separator between two adjacent columns. Present only in
2309
+ * edit mode — the separators ARE the gutter there. */
2310
+ columnResizer: 'data-blok-column-resizer',
2311
+ /** Present on a columns row whose gutter comes from the container's own
2312
+ * column-gap instead of from resizer elements. Set in read-only mode, where
2313
+ * no resizers are built — the discriminator between an editable row and a
2314
+ * published one. */
2315
+ columnsStaticGutter: 'data-blok-columns-static-gutter',
2316
+ // ============================================
2209
2317
  // Nested Blocks
2210
2318
  // ============================================
2211
2319
  /** Container that hosts nested block holders (table cells, toggle/callout/header children).
@@ -2312,31 +2420,208 @@ const createSelector = (attr, value) => {
2312
2420
  return `[${attr}="${value}"]`;
2313
2421
  };
2314
2422
 
2423
+ /**
2424
+ * HTML manipulation utilities
2425
+ */
2426
+ /**
2427
+ * Strips fake background wrapper elements from HTML content.
2428
+ * These elements are used by the inline toolbar for visual selection highlighting
2429
+ * and should not be persisted in saved data.
2430
+ * @param html - HTML content that may contain fake background elements
2431
+ * @returns HTML content with fake background wrappers removed but their content preserved
2432
+ */
2433
+ const stripFakeBackgroundElements = (html) => {
2434
+ if (!html || !html.includes('data-blok-fake-background')) {
2435
+ return html;
2436
+ }
2437
+ const tempDiv = document.createElement('div');
2438
+ tempDiv.innerHTML = html;
2439
+ const fakeBackgrounds = tempDiv.querySelectorAll('[data-blok-fake-background="true"]');
2440
+ fakeBackgrounds.forEach((element) => {
2441
+ const parent = element.parentNode;
2442
+ if (!parent) {
2443
+ return;
2444
+ }
2445
+ while (element.firstChild) {
2446
+ parent.insertBefore(element.firstChild, element);
2447
+ }
2448
+ parent.removeChild(element);
2449
+ });
2450
+ return tempDiv.innerHTML;
2451
+ };
2452
+ /**
2453
+ * Repositions an already-attached node within `parent`, inserting it before
2454
+ * `reference` (or at the end when `reference` is null), while preserving the
2455
+ * node's live state — an iframe keeps its loaded document, media keeps playing,
2456
+ * form fields keep focus and value.
2457
+ *
2458
+ * A plain insertBefore/appendChild detaches and re-attaches the node, which
2459
+ * resets exactly that state (re-parented iframes reload). The browser's
2460
+ * state-preserving move (`moveBefore`, Chrome 133+) avoids the detach; we fall
2461
+ * back to insertBefore where it is unavailable or throws (e.g. cross-document
2462
+ * moves, or a node that is not connected).
2463
+ */
2464
+ const statefulMove = (parent, node, reference) => {
2465
+ if (reference === node) {
2466
+ return;
2467
+ }
2468
+ if (typeof parent.moveBefore === 'function' && node.isConnected && parent.isConnected) {
2469
+ try {
2470
+ parent.moveBefore(node, reference);
2471
+ return;
2472
+ }
2473
+ catch {
2474
+ // moveBefore enforces stricter invariants than insertBefore (same
2475
+ // document, connected node); fall back to a plain, state-resetting insert.
2476
+ }
2477
+ }
2478
+ parent.insertBefore(node, reference);
2479
+ };
2480
+ /**
2481
+ * Moves `node` to sit immediately before `reference`, preserving live state.
2482
+ * @see statefulMove
2483
+ */
2484
+ const moveElementBefore = (node, reference) => {
2485
+ const parent = reference.parentNode;
2486
+ if (parent instanceof Element) {
2487
+ statefulMove(parent, node, reference);
2488
+ }
2489
+ };
2490
+ /**
2491
+ * Moves `node` to sit immediately after `reference`, preserving live state.
2492
+ * @see statefulMove
2493
+ */
2494
+ const moveElementAfter = (node, reference) => {
2495
+ const parent = reference.parentNode;
2496
+ if (parent instanceof Element) {
2497
+ statefulMove(parent, node, reference.nextSibling);
2498
+ }
2499
+ };
2500
+ /**
2501
+ * Moves `node` to the end of `parent`'s children, preserving live state.
2502
+ * @see statefulMove
2503
+ */
2504
+ const moveElementToEnd = (parent, node) => {
2505
+ statefulMove(parent, node, null);
2506
+ };
2507
+
2508
+ /**
2509
+ * Captures focus + caret, but only the part of it that lives inside `holder` —
2510
+ * a selection anywhere else is none of this move's business and must be left
2511
+ * exactly as it is.
2512
+ * @param holder - the holder about to be moved
2513
+ * @returns the snapshot, or null when the caret is elsewhere
2514
+ */
2515
+ const captureCaretWithin = (holder) => {
2516
+ const ownerDocument = holder.ownerDocument;
2517
+ const activeElement = ownerDocument.activeElement;
2518
+ const focused = activeElement instanceof HTMLElement && holder.contains(activeElement) ? activeElement : null;
2519
+ const selection = ownerDocument.defaultView?.getSelection() ?? null;
2520
+ const range = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2521
+ const rangeInside = range !== null && holder.contains(range.startContainer) && holder.contains(range.endContainer);
2522
+ if (focused === null && !rangeInside) {
2523
+ return null;
2524
+ }
2525
+ return {
2526
+ focused,
2527
+ start: rangeInside && range !== null ? [range.startContainer, range.startOffset] : null,
2528
+ end: rangeInside && range !== null ? [range.endContainer, range.endOffset] : null,
2529
+ };
2530
+ };
2531
+ /**
2532
+ * Re-applies a snapshot, but only where the move actually destroyed it —
2533
+ * a browser with the state-preserving move keeps focus and the caret itself,
2534
+ * and re-setting an identical range there would emit a spurious
2535
+ * `selectionchange` at every container render.
2536
+ * @param snapshot - what {@link captureCaretWithin} read, or null
2537
+ */
2538
+ const restoreCaret = (snapshot) => {
2539
+ if (snapshot === null) {
2540
+ return;
2541
+ }
2542
+ const { focused, start, end } = snapshot;
2543
+ if (focused !== null && focused.isConnected && focused.ownerDocument.activeElement !== focused) {
2544
+ focused.focus({ preventScroll: true });
2545
+ }
2546
+ if (start === null || end === null || !start[0].isConnected) {
2547
+ return;
2548
+ }
2549
+ const ownerDocument = start[0].ownerDocument;
2550
+ const selection = ownerDocument?.defaultView?.getSelection() ?? null;
2551
+ if (ownerDocument === null || selection === null) {
2552
+ return;
2553
+ }
2554
+ const live = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
2555
+ const intact = live !== null &&
2556
+ live.startContainer === start[0] &&
2557
+ live.startOffset === start[1] &&
2558
+ live.endContainer === end[0] &&
2559
+ live.endOffset === end[1];
2560
+ if (intact) {
2561
+ return;
2562
+ }
2563
+ const range = ownerDocument.createRange();
2564
+ range.setStart(start[0], start[1]);
2565
+ range.setEnd(end[0], end[1]);
2566
+ selection.removeAllRanges();
2567
+ selection.addRange(range);
2568
+ };
2315
2569
  /**
2316
2570
  * Mount child block holders into a container, skipping children that are
2317
2571
  * already in place or claimed by another nested-blocks container.
2318
2572
  *
2319
- * Used by toggle, header, column, and callout tools to reconcile child holders
2320
- * during the `rendered()` lifecycle hook.
2573
+ * Used by toggle, header, column, and callout tools — and by the React, Vue and
2574
+ * Angular block adapters — to reconcile child holders during the `rendered()`
2575
+ * lifecycle hook. It is the standing DOM↔model reconciler for container blocks:
2576
+ * safe to call on every render, and the only thing that heals a child holder
2577
+ * that core parked outside the container while the container's own slot had not
2578
+ * been created yet.
2579
+ * @param container - the container element child holders belong in; usually the
2580
+ * element carrying `data-blok-nested-blocks`.
2581
+ * @param children - the container block's MODEL children, in model order (e.g.
2582
+ * `api.blocks.getChildren(blockId)`).
2321
2583
  */
2322
2584
  const mountChildBlocks = (container, children) => {
2323
- for (const child of children) {
2585
+ /**
2586
+ * Places a holder at its MODEL position: before the first later child that is
2587
+ * already mounted here, or at the end when there is none. A plain appendChild
2588
+ * would put a reclaimed middle child last — children [A, B, C] with only B
2589
+ * stranded would render A, C, B.
2590
+ *
2591
+ * The move goes through the state-preserving helpers rather than raw
2592
+ * insertBefore/appendChild: re-parenting a live node runs the DOM removing
2593
+ * steps, which blur the focused contenteditable and relocate every live Range
2594
+ * out of the moved subtree — the user's caret vanishes to <body> mid-typing.
2595
+ * Where the browser has no state-preserving move, the caret is put back by
2596
+ * hand around the move.
2597
+ * @param holder - the holder to mount
2598
+ * @param index - the holder's position in `children`
2599
+ */
2600
+ const mountAtModelPosition = (holder, index) => {
2601
+ const anchor = children
2602
+ .slice(index + 1)
2603
+ .find(next => next.holder.parentElement === container)?.holder ?? null;
2604
+ const caret = captureCaretWithin(holder);
2605
+ anchor !== null ? moveElementBefore(holder, anchor) : moveElementToEnd(container, holder);
2606
+ restoreCaret(caret);
2607
+ };
2608
+ for (const [index, child] of children.entries()) {
2324
2609
  if (child.holder.parentElement === container) {
2325
2610
  continue;
2326
2611
  }
2327
2612
  const nested = child.holder.closest(`[${DATA_ATTR.nestedBlocks}]`);
2328
- // Claim a holder stranded in the column_list ROW. A drag reparent into a
2613
+ // Claim a holder stranded in an ANCESTOR nested container. Callers pass
2614
+ // this container's own MODEL children, and a container that ENCLOSES this
2615
+ // one can never be a legitimate home for them — that is model/DOM
2616
+ // divergence by definition. Two ways to get there: a drag reparent into a
2329
2617
  // 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);
2618
+ // (where it renders as a rogue extra column), and a newly inserted first
2619
+ // child is anchored as the container block's DOM *sibling*, i.e. inside
2620
+ // whatever nested container encloses it — permanent when the destination
2621
+ // slot had not committed yet (a framework adapter's portal commits a render
2622
+ // later than core inserts), because nothing else ever re-runs the mount.
2623
+ if (nested !== null && nested !== container && nested.contains(container)) {
2624
+ mountAtModelPosition(child.holder, index);
2340
2625
  continue;
2341
2626
  }
2342
2627
  // Otherwise leave holders that already live inside another nested container
@@ -2344,10 +2629,26 @@ const mountChildBlocks = (container, children) => {
2344
2629
  if (nested !== null) {
2345
2630
  continue;
2346
2631
  }
2347
- container.appendChild(child.holder);
2632
+ mountAtModelPosition(child.holder, index);
2348
2633
  }
2349
2634
  };
2350
2635
 
2636
+ /**
2637
+ * Fired when a CONTAINER block's child holders have been mounted into its
2638
+ * child slot — the moment the child DOM has settled and a caret set into a
2639
+ * freshly inserted child sticks.
2640
+ *
2641
+ * Emitted by the React, Vue and Angular block adapters, whose portals commit a
2642
+ * frame AFTER core's `rendered()` hook; a vanilla container tool mounts its
2643
+ * children synchronously inside `rendered()`, so for those `block:rendered`
2644
+ * already means "settled". It fires on every reconciliation pass of the slot,
2645
+ * not only the first, so treat it as a settle signal rather than a change
2646
+ * signal.
2647
+ *
2648
+ * Public event name: `block:childrenMounted`.
2649
+ */
2650
+ const BlockChildrenMounted = 'block:childrenMounted';
2651
+
2351
2652
  /**
2352
2653
  * The blessed all-in-one Angular component for embedding Blok (mirrors React's
2353
2654
  * `BlokEditor`). Delegates instance lifecycle to an internal `BlokContentDirective`
@@ -2361,8 +2662,8 @@ const mountChildBlocks = (container, children) => {
2361
2662
  * `styleTokens` through `editor.tokens.set()` (replace semantics),
2362
2663
  * `i18n` through `editor.i18n.update()` (deep-equal–deduped; seeded at
2363
2664
  * construction so the locale resolves during boot) and `data`
2364
- * through `editor.render()` (deep-equal–deduped); everything else seeds
2365
- * construction.
2665
+ * through `editor.render()` (content-deduped via `equalsOutputData`); everything
2666
+ * else seeds construction.
2366
2667
  *
2367
2668
  * Implementation note: classic `@Input()`/`@ViewChild()` decorators are used
2368
2669
  * (not signal `input()`/`viewChild()`) for JIT compatibility — see
@@ -2495,9 +2796,12 @@ class BlokEditorComponent {
2495
2796
  /** Changing this input's identity destroys and recreates the editor (≙ React `deps`). */
2496
2797
  recreateKey;
2497
2798
  /**
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`.
2799
+ * Content the editor currently reflects. Set when seeded, when the data effect
2800
+ * renders, when the editor emits its own output (`coreOnSave`) and when the
2801
+ * imperative `render()` facade lands. A controlled `data` echo that
2802
+ * content-equals this baseline is a no-op, so it won't clobber the caret.
2803
+ * Renders are serialized via `renderChain`. `undefined` means "nothing recorded
2804
+ * yet" — never an empty document.
2501
2805
  */
2502
2806
  lastRenderedData;
2503
2807
  /** Last token set pushed through `tokens.set`, for deep-equal deduping. */
@@ -2519,16 +2823,91 @@ class BlokEditorComponent {
2519
2823
  * it straight back into `data` deep-equals the baseline and is deduped to a
2520
2824
  * no-op (no redundant render, no caret reset). Re-enters the Angular zone since
2521
2825
  * the editor runs outside it.
2826
+ *
2827
+ * Falls back to an escape-hatch `[config]` callback when nothing Angular-side
2828
+ * consumes the save — the baseline is recorded either way, so the controlled
2829
+ * `data` dedupe works on both paths.
2522
2830
  */
2523
- coreOnSave = (data) => {
2831
+ coreOnSave = (data, api) => {
2524
2832
  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
- });
2833
+ if (this.hasSaveConsumer()) {
2834
+ this.ngZone.run(() => {
2835
+ this.dataChange.emit(data);
2836
+ this.save.emit(data);
2837
+ this.cvaOnChange?.(data);
2838
+ this.cvaOnTouched?.();
2839
+ });
2840
+ return;
2841
+ }
2842
+ this.escapeHatchConfig().onSave?.(data, api);
2531
2843
  };
2844
+ /**
2845
+ * Stable wrappers for the remaining live callbacks. Each resolves its target
2846
+ * at call time (an observed output, else the `[config]` escape hatch), so a
2847
+ * pushed handler never goes stale and only PRESENCE has to be synced.
2848
+ */
2849
+ coreOnChange = (api, event) => {
2850
+ if (this.change.observed) {
2851
+ this.ngZone.run(() => this.change.emit({ api, event }));
2852
+ return;
2853
+ }
2854
+ this.escapeHatchConfig().onChange?.(api, event);
2855
+ };
2856
+ coreOnAfterRender = (api) => {
2857
+ if (this.afterRender.observed) {
2858
+ this.ngZone.run(() => this.afterRender.emit(api));
2859
+ return;
2860
+ }
2861
+ this.escapeHatchConfig().onAfterRender?.(api);
2862
+ };
2863
+ /*
2864
+ * Transforms run synchronously inside core's render pipeline and must return a
2865
+ * value — never wrap them in ngZone.run.
2866
+ */
2867
+ coreOnBeforeRender = (blocks) => {
2868
+ const transform = this.onBeforeRender ?? this.escapeHatchConfig().onBeforeRender;
2869
+ return transform?.(blocks) ?? blocks;
2870
+ };
2871
+ coreOnEnter = (event, api) => this.escapeHatchConfig().onEnter?.(event, api);
2872
+ coreOnSubmit = (data, api) => {
2873
+ this.escapeHatchConfig().onSubmit?.(data, api);
2874
+ };
2875
+ /** provideBlok defaults merged under the `[config]` escape hatch. */
2876
+ escapeHatchConfig() {
2877
+ return { ...this.defaults, ...this.config };
2878
+ }
2879
+ /** True when an output, a two-way binding or Angular forms consumes `onSave`. */
2880
+ hasSaveConsumer() {
2881
+ return this.dataChange.observed || this.save.observed || this.cvaOnChange !== undefined;
2882
+ }
2883
+ /**
2884
+ * The live callbacks this component currently wires into core, keyed by
2885
+ * handler name — `undefined` for the ones nothing consumes.
2886
+ *
2887
+ * PRESENCE is the semantics in core (an `onSubmit` turns Enter into
2888
+ * serialize-and-submit, an `onSave` arms the change-observation pipeline), so
2889
+ * an unconsumed callback must stay absent. One source of truth for both the
2890
+ * construction config and the runtime sync in `ngDoCheck`, so the two cannot
2891
+ * drift.
2892
+ * @returns the wrappers to install, `undefined` where nothing is wired
2893
+ */
2894
+ liveHandlers() {
2895
+ const cfg = this.escapeHatchConfig();
2896
+ return {
2897
+ onChange: this.change.observed || cfg.onChange !== undefined ? this.coreOnChange : undefined,
2898
+ onSave: this.hasSaveConsumer() || cfg.onSave !== undefined ? this.coreOnSave : undefined,
2899
+ onEnter: cfg.onEnter !== undefined ? this.coreOnEnter : undefined,
2900
+ onSubmit: cfg.onSubmit !== undefined ? this.coreOnSubmit : undefined,
2901
+ onBeforeRender: this.onBeforeRender !== undefined || cfg.onBeforeRender !== undefined
2902
+ ? this.coreOnBeforeRender
2903
+ : undefined,
2904
+ onAfterRender: this.afterRender.observed || cfg.onAfterRender !== undefined
2905
+ ? this.coreOnAfterRender
2906
+ : undefined,
2907
+ };
2908
+ }
2909
+ /** Handler presence installed on the live editor (the sync dedupe baseline). */
2910
+ appliedHandlers = {};
2532
2911
  /** Live Blok instance, or null until `isReady` resolves / after destroy. */
2533
2912
  instance = computed(() => this.content()?.instance() ?? null, /* @ts-ignore */
2534
2913
  ...(ngDevMode ? [{ debugName: "instance" }] : /* istanbul ignore next */ []));
@@ -2589,27 +2968,24 @@ class BlokEditorComponent {
2589
2968
  if (i18n !== undefined) {
2590
2969
  cfg.i18n = i18n;
2591
2970
  }
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
- }
2971
+ // Opt-in: only attach each live callback when the consumer actually consumes
2972
+ // it (an observed output, a registered forms hook, a provided transform or an
2973
+ // escape-hatch config callback). Their mere presence makes the core serialize
2974
+ // / run hooks on every change. The same map drives the runtime sync in
2975
+ // `ngDoCheck`, so construction and post-mount can never disagree.
2976
+ const handlers = this.liveHandlers();
2977
+ cfg.onChange = handlers.onChange;
2978
+ cfg.onSave = handlers.onSave;
2979
+ cfg.onEnter = handlers.onEnter;
2980
+ cfg.onSubmit = handlers.onSubmit;
2981
+ cfg.onBeforeRender = handlers.onBeforeRender;
2982
+ cfg.onAfterRender = handlers.onAfterRender;
2983
+ this.appliedHandlers = handlers;
2604
2984
  if (this.themeChange.observed) {
2605
2985
  cfg.onThemeChange = (resolved) => this.ngZone.run(() => this.themeChange.emit(resolved));
2606
2986
  }
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
- }
2987
+ // Transforms run synchronously inside core's paste pipeline and must return a
2988
+ // value — never wrap them in ngZone.run.
2613
2989
  const beforePaste = this.onBeforePaste;
2614
2990
  if (beforePaste !== undefined) {
2615
2991
  cfg.onBeforePaste = (html) => beforePaste(html);
@@ -2623,6 +2999,62 @@ class BlokEditorComponent {
2623
2999
  ngAfterViewInit() {
2624
3000
  this.content.set(this.contentQuery ?? null);
2625
3001
  }
3002
+ /**
3003
+ * Reactive callback presence.
3004
+ *
3005
+ * Callback wiring was decided once, at construction, and in core the presence
3006
+ * of a handler IS the semantics: an `onSubmit` makes Enter serialize-and-submit
3007
+ * instead of splitting the block, an `onSave` arms the whole change-observation
3008
+ * pipeline. A `[config]` swap, an `*ngIf`-gated `(save)` output or a
3009
+ * `registerOnChange` from Angular forms arriving after mount therefore needed a
3010
+ * `recreateKey` bump — destroying the editor and losing caret and undo history.
3011
+ *
3012
+ * Diff the wired handlers against what is installed and push genuine flips
3013
+ * through the runtime `handlers.set` API, writing `undefined` for a handler
3014
+ * that lost its consumer so the change stays reversible. `ngDoCheck` (not an
3015
+ * `effect`) because `EventEmitter.observed` and the plain `@Input` transforms
3016
+ * are not signals; the comparison is six identity checks against stable
3017
+ * wrappers, so an unchanged cycle pushes nothing.
3018
+ */
3019
+ ngDoCheck() {
3020
+ const editor = this.instance();
3021
+ if (editor === null) {
3022
+ return;
3023
+ }
3024
+ const desired = this.liveHandlers();
3025
+ const applied = this.appliedHandlers;
3026
+ const diff = {};
3027
+ const changed = { value: false };
3028
+ const sync = (key, write) => {
3029
+ if (desired[key] === applied[key]) {
3030
+ return;
3031
+ }
3032
+ applied[key] = desired[key];
3033
+ changed.value = true;
3034
+ write(desired[key]);
3035
+ };
3036
+ sync('onChange', (value) => {
3037
+ diff.onChange = value;
3038
+ });
3039
+ sync('onSave', (value) => {
3040
+ diff.onSave = value;
3041
+ });
3042
+ sync('onEnter', (value) => {
3043
+ diff.onEnter = value;
3044
+ });
3045
+ sync('onSubmit', (value) => {
3046
+ diff.onSubmit = value;
3047
+ });
3048
+ sync('onBeforeRender', (value) => {
3049
+ diff.onBeforeRender = value;
3050
+ });
3051
+ sync('onAfterRender', (value) => {
3052
+ diff.onAfterRender = value;
3053
+ });
3054
+ if (changed.value) {
3055
+ editor.handlers.set(diff);
3056
+ }
3057
+ }
2626
3058
  // ---- Curated imperative facade (delegates to the live instance; no-ops until ready) ----
2627
3059
  /** Serialize the current content. Resolves undefined until the editor is ready. */
2628
3060
  save$() {
@@ -2632,9 +3064,24 @@ class BlokEditorComponent {
2632
3064
  focus(atEnd) {
2633
3065
  this.instance()?.focus(atEnd);
2634
3066
  }
2635
- /** Replace the editor content. Resolves undefined until the editor is ready. */
3067
+ /**
3068
+ * Replace the editor content. Resolves undefined until the editor is ready.
3069
+ *
3070
+ * Safe to mix with a controlled `[data]` input: the rendered document becomes
3071
+ * the content baseline once the call lands, so a later `[data]` change back to
3072
+ * the previous document still re-renders instead of being deduped against a
3073
+ * baseline the editor no longer reflects. Recorded only on the resolved path —
3074
+ * a failed render leaves the editor on its previous content.
3075
+ * @param data - the document to render
3076
+ */
2636
3077
  render(data) {
2637
- return this.instance()?.render(data);
3078
+ const editor = this.instance();
3079
+ if (editor === null) {
3080
+ return undefined;
3081
+ }
3082
+ return editor.render(data).then(() => {
3083
+ this.lastRenderedData = data;
3084
+ });
2638
3085
  }
2639
3086
  constructor() {
2640
3087
  // Each effect reads `instance()` so it re-applies once the editor appears
@@ -2755,7 +3202,14 @@ class BlokEditorComponent {
2755
3202
  this.lastRenderedData = data;
2756
3203
  return;
2757
3204
  }
2758
- if (deepEqual(data, this.lastRenderedData)) {
3205
+ // Structural comparison (`equalsOutputData`, not a raw deep-equal): a host
3206
+ // that persists the editor's own document and hands back a stripped copy —
3207
+ // fresh `time`, dropped ids, no `lastEditedAt` stamp — is still echoing
3208
+ // content the editor already shows, and re-rendering it would reset the
3209
+ // caret for zero visual change. `undefined` means "nothing recorded yet",
3210
+ // which is NOT an empty document, so it must never match.
3211
+ const baseline = this.lastRenderedData;
3212
+ if (baseline !== undefined && equalsOutputData(data, baseline)) {
2759
3213
  return;
2760
3214
  }
2761
3215
  this.lastRenderedData = data;
@@ -2895,6 +3349,76 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
2895
3349
  }] } });
2896
3350
 
2897
3351
  // packages/angular/src/createAngularBlock.ts
3352
+ /** Statics the generated class owns; an authored `statics` bag can never take them over. */
3353
+ const RESERVED_STATICS = ['toolbox', 'isReadOnlySupported', '__isBlokAngularBlock'];
3354
+ /**
3355
+ * Origins that mean "the author just made this block" — the only ones that fire
3356
+ * {@link CreateAngularBlockSpec.onCreated}. Written as an allow-list so a future
3357
+ * core origin fails CLOSED (no creation signal) instead of silently opting in.
3358
+ *
3359
+ * `undefined` is included because core always supplies an origin: an absent one
3360
+ * means a host hand-built the constructor options, which is an explicit
3361
+ * creation — the same reading core's own container tools apply.
3362
+ *
3363
+ * Note what is NOT the axis here: `origin === 'user'`. A container gated on that
3364
+ * alone would refuse to seed for `api.blocks.insert('steps')` and for a
3365
+ * turn-into, leaving an empty, unusable container.
3366
+ */
3367
+ const CREATION_ORIGINS = new Set([
3368
+ undefined,
3369
+ 'user',
3370
+ 'api',
3371
+ 'convert',
3372
+ ]);
3373
+ /**
3374
+ * Apply one pass of per-child holder attributes and clear the previous pass's
3375
+ * leftovers — including on a child that has since left the container, which
3376
+ * would otherwise keep a dead index forever.
3377
+ * @param stamped - the container's ledger of what the last pass wrote (mutated)
3378
+ * @param children - the container's model children, in model order
3379
+ * @param decorate - the authored per-child decorator, if any
3380
+ */
3381
+ const applyChildAttributes = (stamped, children, decorate) => {
3382
+ const next = new Map();
3383
+ children.forEach((child, index) => {
3384
+ const names = [];
3385
+ for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
3386
+ if (value === null || value === undefined) {
3387
+ child.holder.removeAttribute(name);
3388
+ continue;
3389
+ }
3390
+ child.holder.setAttribute(name, String(value));
3391
+ names.push(name);
3392
+ }
3393
+ next.set(child.id, { holder: child.holder, names });
3394
+ });
3395
+ for (const [id, previous] of stamped) {
3396
+ const current = next.get(id);
3397
+ previous.names
3398
+ .filter(name => current === undefined || !current.names.includes(name))
3399
+ .forEach(name => previous.holder.removeAttribute(name));
3400
+ }
3401
+ stamped.clear();
3402
+ next.forEach((entry, id) => stamped.set(id, entry));
3403
+ };
3404
+ /**
3405
+ * Announce that a container's child holders have settled in its slot — the
3406
+ * signal a host waits on before putting the caret into a freshly inserted
3407
+ * child.
3408
+ *
3409
+ * Optional-chained: the editor always supplies `events`, but a host unit test
3410
+ * may hand a block a partial `api`, and an observability signal must never be
3411
+ * able to break a render.
3412
+ * @param api - the editor-level API
3413
+ * @param blockId - the container block's id
3414
+ * @param children - the container's model children, in model order
3415
+ */
3416
+ const emitChildrenMounted = (api, blockId, children) => {
3417
+ api?.events?.emit(BlockChildrenMounted, {
3418
+ blockId,
3419
+ childIds: children.map(child => child.id),
3420
+ });
3421
+ };
2898
3422
  /**
2899
3423
  * Author a first-party Angular block. Returns a `BlockToolConstructable`
2900
3424
  * registered exactly like a vanilla tool (`tools: { type: { class:
@@ -2912,7 +3436,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
2912
3436
  * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
2913
3437
  */
2914
3438
  function createAngularBlock(spec) {
2915
- return class AngularBlockTool {
3439
+ const AngularBlockTool = class AngularBlockTool {
2916
3440
  /** Marker so the directive can detect Angular-block tools and inject the registry. */
2917
3441
  static __isBlokAngularBlock = true;
2918
3442
  static get toolbox() {
@@ -2928,6 +3452,8 @@ function createAngularBlock(spec) {
2928
3452
  return true;
2929
3453
  }
2930
3454
  blockApi;
3455
+ /** The editor-level API, handed to the component as `ctx.api`. */
3456
+ api;
2931
3457
  registry;
2932
3458
  pointerDrag;
2933
3459
  dataSig;
@@ -2939,26 +3465,39 @@ function createAngularBlock(spec) {
2939
3465
  hostEl = null;
2940
3466
  /** Last host passed to ctx.mountChildren, re-mounted on each data change. */
2941
3467
  childHost = null;
3468
+ /** Last per-child decorator passed to ctx.mountChildren; re-applied on each remount. */
3469
+ childAttributes;
3470
+ /** What the last decoration pass wrote, so a dropped key is cleaned up. */
3471
+ stampedChildren = new Map();
3472
+ /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
3473
+ origin;
3474
+ /** True once the post-mount hooks fired; a repeated rendered() must not re-fire them. */
3475
+ mountSignalled = false;
2942
3476
  /** True while a pointer drag suppresses dispatchChange. */
2943
3477
  pendingDispatch = false;
2944
3478
  constructor(options) {
2945
3479
  this.blockApi = options.block;
2946
3480
  const config = (options.config ?? {});
2947
3481
  this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY];
3482
+ this.api = options.api;
2948
3483
  // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
2949
3484
  // (core silently drops a dispatchChange while a drag is active).
2950
- const api = options.api;
2951
- this.pointerDrag = () => api?.blocks?.isPointerDragActive === true;
3485
+ const drag = options.api;
3486
+ this.pointerDrag = () => drag?.blocks?.isPointerDragActive === true;
2952
3487
  this.mirror = fillDefaults(spec.propSchema, (options.data ?? {}));
2953
3488
  this.lastRendered = this.mirror;
2954
3489
  this.dataSig = signal(this.mirror, /* @ts-ignore */
2955
3490
  ...(ngDevMode ? [{ debugName: "dataSig" }] : /* istanbul ignore next */ []));
2956
3491
  this.readOnlySig = signal(options.readOnly, /* @ts-ignore */
2957
3492
  ...(ngDevMode ? [{ debugName: "readOnlySig" }] : /* istanbul ignore next */ []));
3493
+ // Absent origin means a caller that predates the signal; 'api' is core's
3494
+ // own default, so it is never mistaken for a user gesture.
3495
+ this.origin = options.origin ?? 'api';
2958
3496
  this.ctx = {
2959
3497
  data: this.dataSig.asReadonly(),
2960
3498
  commit: this.commit,
2961
3499
  block: this.blockApi,
3500
+ api: this.api,
2962
3501
  readOnly: this.readOnlySig.asReadonly(),
2963
3502
  mountChildren: this.mountChildren,
2964
3503
  };
@@ -2978,6 +3517,25 @@ function createAngularBlock(spec) {
2978
3517
  }
2979
3518
  rendered() {
2980
3519
  spec.onRendered?.(this.blockApi);
3520
+ // register() mounted the component and ran its first change detection
3521
+ // synchronously inside render(), and core has now put the host in the
3522
+ // document — so this is the settled moment. Once per instance: core
3523
+ // re-runs rendered() for a re-materialised block, and a repeated creation
3524
+ // signal would seed a container's default children twice. With no
3525
+ // registry (vanilla core, no directive) nothing was ever mounted, so
3526
+ // there is no settle to report — matching React/Vue, where the signal
3527
+ // originates in the component itself.
3528
+ if (this.registry === undefined || this.mountSignalled) {
3529
+ return;
3530
+ }
3531
+ this.mountSignalled = true;
3532
+ const context = { origin: this.origin, api: this.api };
3533
+ spec.onMounted?.(this.blockApi, context);
3534
+ // Creation-only, and after onMounted: a seeding hook must see whatever
3535
+ // the mount hook already put in place.
3536
+ if (CREATION_ORIGINS.has(this.origin)) {
3537
+ spec.onCreated?.(this.blockApi, context);
3538
+ }
2981
3539
  }
2982
3540
  save() {
2983
3541
  return this.mirror;
@@ -3012,29 +3570,55 @@ function createAngularBlock(spec) {
3012
3570
  this.readOnlySig.set(state);
3013
3571
  this.registry?.flush(this.blockApi.id);
3014
3572
  }
3573
+ /**
3574
+ * Core's toolbar-anchor hook. Always defined so the delegation is one
3575
+ * `typeof … === 'function'` probe away, and always resolved fresh against
3576
+ * the LIVE host — the anchor element is Angular-rendered, so it does not
3577
+ * exist yet when the tool is constructed and may be replaced on any change
3578
+ * detection pass. A spec without a resolver returns undefined, which is
3579
+ * exactly what core's default positioning already assumes.
3580
+ */
3581
+ getToolbarAnchorElement() {
3582
+ const host = this.hostEl;
3583
+ if (host === null || spec.getToolbarAnchorElement === undefined) {
3584
+ return undefined;
3585
+ }
3586
+ return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
3587
+ }
3015
3588
  moved() {
3016
3589
  // No remount: core relocates the host element; the mounted view rides along
3017
3590
  // as its DOM children.
3018
3591
  spec.onMoved?.(this.blockApi);
3019
3592
  }
3020
3593
  removed() {
3021
- this.registry?.unregister(this.blockApi.id);
3594
+ // Ownership-scoped: core composes a REPLACEMENT block (which mounts under
3595
+ // the SAME id) before it tears this one down, so an unqualified unregister
3596
+ // here would destroy the live componentRef and blank the block.
3597
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
3022
3598
  spec.onRemoved?.(this.blockApi);
3023
3599
  }
3024
3600
  destroy() {
3025
3601
  // Idempotent with removed(); unregister is safe when already absent.
3026
- this.registry?.unregister(this.blockApi.id);
3602
+ this.registry?.unregister(this.blockApi.id, this.hostEl ?? undefined);
3027
3603
  }
3028
- /** Container blocks: remember the host and (re)mount the real child holders. */
3029
- mountChildren = (host) => {
3604
+ /**
3605
+ * Container blocks: remember the host (and any per-child decorator) and
3606
+ * (re)mount the real child holders.
3607
+ */
3608
+ mountChildren = (host, childAttributes) => {
3030
3609
  this.childHost = host;
3610
+ this.childAttributes = childAttributes;
3031
3611
  host.setAttribute(DATA_ATTR.nestedBlocks, '');
3032
3612
  this.remountChildren();
3033
3613
  };
3034
3614
  remountChildren() {
3035
- if (this.childHost !== null) {
3036
- mountChildBlocks(this.childHost, this.blockApi.getChildren());
3615
+ if (this.childHost === null) {
3616
+ return;
3037
3617
  }
3618
+ const children = this.blockApi.getChildren();
3619
+ mountChildBlocks(this.childHost, children);
3620
+ applyChildAttributes(this.stampedChildren, children, this.childAttributes);
3621
+ emitChildrenMounted(this.api, this.blockApi.id, children);
3038
3622
  }
3039
3623
  /**
3040
3624
  * The only data write path. Merges the patch into the frozen mirror, swaps
@@ -3082,6 +3666,22 @@ function createAngularBlock(spec) {
3082
3666
  requestAnimationFrame(retry);
3083
3667
  }
3084
3668
  };
3669
+ // Forward the authored statics onto the generated class. `defineProperty`
3670
+ // (not assignment) because `toolbox`/`isReadOnlySupported` are accessors and a
3671
+ // plain write would throw in strict mode; the reserved list keeps a stray bag
3672
+ // from taking those — or the adapter's own marker — over.
3673
+ for (const [key, value] of Object.entries(spec.statics ?? {})) {
3674
+ if (RESERVED_STATICS.includes(key)) {
3675
+ continue;
3676
+ }
3677
+ Object.defineProperty(AngularBlockTool, key, {
3678
+ value,
3679
+ writable: true,
3680
+ enumerable: true,
3681
+ configurable: true,
3682
+ });
3683
+ }
3684
+ return AngularBlockTool;
3085
3685
  }
3086
3686
 
3087
3687
  // packages/angular/src/useBlocks.ts
@@ -3266,5 +3866,5 @@ function injectBlokReady(options = {}) {
3266
3866
  * Generated bundle index. Do not edit.
3267
3867
  */
3268
3868
 
3269
- export { BLOK_BLOCK_CONTEXT, BLOK_DEFAULT_CONFIG, BlokContentDirective, BlokEditorComponent, createAngularBlock, injectBlocks, injectBlokReady, provideBlok };
3869
+ export { BLOK_BLOCK_CONTEXT, BLOK_DEFAULT_CONFIG, BLOK_EDITOR_INSTANCE, BlokContentDirective, BlokEditorComponent, createAngularBlock, injectBlocks, injectBlokInstance, injectBlokReady, provideBlok };
3270
3870
  //# sourceMappingURL=bloklabs-angular.mjs.map