@bloklabs/angular 1.9.0 → 1.10.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.
@@ -765,6 +765,73 @@ const EMPTY_API = {
765
765
  clear: async () => undefined,
766
766
  isSyncingFromYjs: () => false,
767
767
  };
768
+ /**
769
+ * Read the changed block's id out of a `block changed` payload, tolerating any
770
+ * shape that is not core's `{ event: { detail: { target } } }`.
771
+ * @param payload - whatever the dispatcher handed the listener
772
+ * @returns the target block id, or null when the payload does not carry one
773
+ */
774
+ const changedBlockId = (payload) => {
775
+ const detail = payload
776
+ ?.event?.detail?.target?.id;
777
+ return typeof detail === 'string' ? detail : null;
778
+ };
779
+ /**
780
+ * Does a `block changed` event touch the subtree rooted at `withinId`?
781
+ *
782
+ * The reactivity filter behind `useBlocks(editor, { within })` in all three
783
+ * adapters. Without it every consumer of `useBlocks` re-renders on every change
784
+ * anywhere in the document — so a container block that only renders its own
785
+ * children still re-rendered on each keystroke in an unrelated block, and a page
786
+ * of N such containers turned one keystroke into N re-renders.
787
+ *
788
+ * INDETERMINATE READS AS "TOUCHED". The walk resolves ancestry through the LIVE
789
+ * tree, and a removal commonly emits after the block is already gone, so its
790
+ * parent chain cannot be read. Answering `false` there would leave a container
791
+ * rendering a child that no longer exists; answering `true` costs one extra
792
+ * render pass. Same for a payload with no readable target.
793
+ *
794
+ * Ancestry is walked UPWARD one `getById` at a time rather than snapshotted with
795
+ * `parentMap`: this runs on every emission — i.e. on every keystroke — so it must
796
+ * cost O(depth), not O(document).
797
+ * @param editor - the live Blok instance
798
+ * @param payload - the `block changed` payload
799
+ * @param withinId - id of the block whose subtree is the scope
800
+ * @returns true when the change is inside the scope (or cannot be placed)
801
+ */
802
+ const changeTouchesSubtree = (editor, payload, withinId) => {
803
+ const targetId = changedBlockId(payload);
804
+ if (targetId === null) {
805
+ return true;
806
+ }
807
+ /** Ids already visited on this walk — one Set for the whole climb. */
808
+ const seen = new Set();
809
+ /**
810
+ * Walk one link up the parentId chain.
811
+ * @param id - the block to test, or null once the root is passed
812
+ * @returns true when `withinId` is `id` or one of its ancestors
813
+ */
814
+ const climbs = (id) => {
815
+ if (id === null) {
816
+ return false;
817
+ }
818
+ if (id === withinId) {
819
+ return true;
820
+ }
821
+ // A parentId cycle is a corrupt tree, not a scope hit — bail rather than spin.
822
+ if (seen.has(id)) {
823
+ return false;
824
+ }
825
+ seen.add(id);
826
+ const node = editor.blocks.getById(id);
827
+ // The target (or one of its ancestors) is no longer in the tree.
828
+ if (node === null || node === undefined) {
829
+ return true;
830
+ }
831
+ return climbs(node.parentId);
832
+ };
833
+ return climbs(targetId);
834
+ };
768
835
  /**
769
836
  * Build the framework-agnostic, id/parentId-relative block-tree API over a LIVE
770
837
  * editor. Mutators route through the editor-level `blocks` API (core's
@@ -2741,6 +2808,101 @@ const mountChildBlocks = (container, children) => {
2741
2808
  }
2742
2809
  };
2743
2810
 
2811
+ /**
2812
+ * A fresh pair of ledgers for one container's child slot.
2813
+ * @returns empty holder/content ledgers
2814
+ */
2815
+ const createChildDecorationLedger = () => ({
2816
+ holders: new Map(),
2817
+ contents: new Map(),
2818
+ });
2819
+ /**
2820
+ * The child's OWN content wrapper, or null when its DOM has not committed yet.
2821
+ *
2822
+ * The first `[data-blok-element-content]` in document order under a holder is
2823
+ * always that block's own: a descendant block's wrapper necessarily sits INSIDE
2824
+ * it. Not `firstElementChild` — block tunes wrap the content node, so it is not
2825
+ * reliably a direct child (this is the same lookup core's own
2826
+ * `refreshToolRootElement` uses).
2827
+ * @param holder - the child block's holder
2828
+ * @returns the content wrapper, or null
2829
+ */
2830
+ const contentWrapperOf = (holder) => holder.querySelector(`[${DATA_ATTR.elementContent}]`);
2831
+ /**
2832
+ * Apply one decoration pass and clear the previous pass's leftovers — including
2833
+ * on a child that has since left the container, which would otherwise keep a
2834
+ * dead index forever.
2835
+ * @param ledger - what the last pass wrote (mutated)
2836
+ * @param children - the container's model children, in model order
2837
+ * @param decorate - the per-child decorator for the child HOLDERS, if any
2838
+ * @param resolveTarget - which element of a child this pass writes to
2839
+ */
2840
+ const applyPass = (ledger, children, decorate, resolveTarget) => {
2841
+ const next = new Map();
2842
+ children.forEach((child, index) => {
2843
+ const target = resolveTarget(child.holder);
2844
+ /**
2845
+ * A portal-rendered child commits its DOM a frame after core inserts it, so
2846
+ * its content wrapper can legitimately be missing on this pass. Skipping
2847
+ * (rather than throwing) leaves the next pass to stamp it.
2848
+ */
2849
+ if (target === null) {
2850
+ return;
2851
+ }
2852
+ const names = [];
2853
+ for (const [name, value] of Object.entries(decorate?.(child, index) ?? {})) {
2854
+ if (value === null || value === undefined) {
2855
+ target.removeAttribute(name);
2856
+ continue;
2857
+ }
2858
+ target.setAttribute(name, String(value));
2859
+ names.push(name);
2860
+ }
2861
+ next.set(child.id, { target, names });
2862
+ });
2863
+ for (const [id, previous] of ledger) {
2864
+ const current = next.get(id);
2865
+ previous.names
2866
+ .filter(name => current === undefined || !current.names.includes(name))
2867
+ .forEach(name => previous.target.removeAttribute(name));
2868
+ }
2869
+ ledger.clear();
2870
+ next.forEach((entry, id) => ledger.set(id, entry));
2871
+ };
2872
+ /**
2873
+ * Decorate a container's children: named attributes on each child's HOLDER and,
2874
+ * separately, on each child's `[data-blok-element-content]` wrapper.
2875
+ *
2876
+ * The single implementation behind the React, Vue and Angular per-child
2877
+ * decoration channels, so the three cannot drift on which element a hook lands
2878
+ * on or on when a dropped hook is cleaned up.
2879
+ *
2880
+ * BOTH levels exist because core's child-holder decoration law blesses both, and
2881
+ * a container needs both: the holder is the child's outer box (rails, indices,
2882
+ * hover states), while the content wrapper is where the child's own text box
2883
+ * begins — which is what a numbered rail or a connector line has to align to.
2884
+ * With only the holder reachable, a container had to encode core's wrapper chain
2885
+ * in its own CSS (`[data-step] > [data-blok-element-content] > …`) and broke
2886
+ * whenever that chain changed. Neither hook may introduce an element: holders
2887
+ * stay DIRECT children of the slot because core's reparenting and caret
2888
+ * sibling checks compare `holder.parentElement` by identity.
2889
+ *
2890
+ * Both are inert with respect to change tracking: core's mutation filter drops a
2891
+ * holder- or content-targeted attribute record for the CHILD block (both are
2892
+ * ancestors of the child's tool element, not descendants) and scores it
2893
+ * mutation-free for the CONTAINER (whose own `data-blok-mutation-free` host is
2894
+ * the nearest such ancestor).
2895
+ * @param ledger - the slot's ledgers from {@link createChildDecorationLedger}
2896
+ * @param children - the container's model children, in model order
2897
+ * @param decorators - the authored per-child decorators
2898
+ * @param decorators.childAttributes - written on each child's holder
2899
+ * @param decorators.childContentAttributes - written on each child's content wrapper
2900
+ */
2901
+ const applyChildDecoration = (ledger, children, decorators) => {
2902
+ applyPass(ledger.holders, children, decorators.childAttributes, holder => holder);
2903
+ applyPass(ledger.contents, children, decorators.childContentAttributes, contentWrapperOf);
2904
+ };
2905
+
2744
2906
  /**
2745
2907
  * Fired when a CONTAINER block's child holders have been mounted into its
2746
2908
  * child slot — the moment the child DOM has settled and a caret set into a
@@ -3478,37 +3640,6 @@ const CREATION_ORIGINS = new Set([
3478
3640
  'api',
3479
3641
  'convert',
3480
3642
  ]);
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
3643
  /**
3513
3644
  * Announce that a container's child holders have settled in its slot — the
3514
3645
  * signal a host waits on before putting the caret into a freshly inserted
@@ -3573,10 +3704,17 @@ function createAngularBlock(spec) {
3573
3704
  hostEl = null;
3574
3705
  /** Last host passed to ctx.mountChildren, re-mounted on each data change. */
3575
3706
  childHost = null;
3576
- /** Last per-child decorator passed to ctx.mountChildren; re-applied on each remount. */
3707
+ /** Element the component handed to ctx.setToolbarAnchor, if any. */
3708
+ anchorEl = null;
3709
+ /**
3710
+ * Last per-child decorators passed to ctx.mountChildren; BOTH re-applied on
3711
+ * every remount (the author calls mountChildren once, the factory re-runs the
3712
+ * mount on each data change).
3713
+ */
3577
3714
  childAttributes;
3715
+ childContentAttributes;
3578
3716
  /** What the last decoration pass wrote, so a dropped key is cleaned up. */
3579
- stampedChildren = new Map();
3717
+ childLedger = createChildDecorationLedger();
3580
3718
  /** Why core built this instance — gates `onCreated`, handed to `onMounted`. */
3581
3719
  origin;
3582
3720
  /** True once the post-mount hooks fired; a repeated rendered() must not re-fire them. */
@@ -3608,6 +3746,7 @@ function createAngularBlock(spec) {
3608
3746
  api: this.api,
3609
3747
  readOnly: this.readOnlySig.asReadonly(),
3610
3748
  mountChildren: this.mountChildren,
3749
+ setToolbarAnchor: this.setToolbarAnchor,
3611
3750
  };
3612
3751
  }
3613
3752
  render() {
@@ -3687,12 +3826,30 @@ function createAngularBlock(spec) {
3687
3826
  * exactly what core's default positioning already assumes.
3688
3827
  */
3689
3828
  getToolbarAnchorElement() {
3829
+ /**
3830
+ * An element the component handed over wins — it is the exact node, chosen
3831
+ * from inside the template. `isConnected` is the guard that matters: a
3832
+ * component that re-renders its anchor can leave a detached node here, and
3833
+ * positioning the toolbar against one silently parks it at 0,0.
3834
+ */
3835
+ const declared = this.anchorEl;
3836
+ if (declared !== null && declared.isConnected) {
3837
+ return declared;
3838
+ }
3690
3839
  const host = this.hostEl;
3691
3840
  if (host === null || spec.getToolbarAnchorElement === undefined) {
3692
3841
  return undefined;
3693
3842
  }
3694
3843
  return spec.getToolbarAnchorElement(host, this.blockApi) ?? undefined;
3695
3844
  }
3845
+ /**
3846
+ * `ctx.setToolbarAnchor`. A bound field so the context object handed through
3847
+ * DI keeps a stable identity.
3848
+ * @param element - the anchor element, or null to fall back to the spec hook
3849
+ */
3850
+ setToolbarAnchor = (element) => {
3851
+ this.anchorEl = element;
3852
+ };
3696
3853
  moved() {
3697
3854
  // No remount: core relocates the host element; the mounted view rides along
3698
3855
  // as its DOM children.
@@ -3713,9 +3870,10 @@ function createAngularBlock(spec) {
3713
3870
  * Container blocks: remember the host (and any per-child decorator) and
3714
3871
  * (re)mount the real child holders.
3715
3872
  */
3716
- mountChildren = (host, childAttributes) => {
3873
+ mountChildren = (host, childAttributes, childContentAttributes) => {
3717
3874
  this.childHost = host;
3718
3875
  this.childAttributes = childAttributes;
3876
+ this.childContentAttributes = childContentAttributes;
3719
3877
  host.setAttribute(DATA_ATTR.nestedBlocks, '');
3720
3878
  this.remountChildren();
3721
3879
  };
@@ -3725,7 +3883,10 @@ function createAngularBlock(spec) {
3725
3883
  }
3726
3884
  const children = this.blockApi.getChildren();
3727
3885
  mountChildBlocks(this.childHost, children);
3728
- applyChildAttributes(this.stampedChildren, children, this.childAttributes);
3886
+ applyChildDecoration(this.childLedger, children, {
3887
+ childAttributes: this.childAttributes,
3888
+ childContentAttributes: this.childContentAttributes,
3889
+ });
3729
3890
  emitChildrenMounted(this.api, this.blockApi.id, children);
3730
3891
  }
3731
3892
  /**
@@ -3827,8 +3988,16 @@ const BLOCK_CHANGED_EVENT = 'block changed';
3827
3988
  * `ed === sub.editor` makes it a no-op, so no double-subscription occurs.
3828
3989
  *
3829
3990
  * @param editor - a signal of the Blok instance, or null pre-ready
3991
+ * @param options - reactivity options; see {@link InjectBlocksOptions.within} to
3992
+ * scope invalidation to one block's subtree
3830
3993
  */
3831
- function injectBlocks(editor) {
3994
+ function injectBlocks(editor, options = {}) {
3995
+ /**
3996
+ * The scope, resolved per emission so a signal `within` takes effect with no
3997
+ * re-subscription. A signal is a zero-arg function, which is how it is told
3998
+ * apart from a plain id.
3999
+ */
4000
+ const resolveWithin = () => (typeof options.within === 'function' ? options.within() : options.within) ?? null;
3832
4001
  // Bumped on every `block changed`; the shared API's read methods touch it (via
3833
4002
  // the onRead seam) so template/computed reads stay reactive.
3834
4003
  const version = signal(0, /* @ts-ignore */
@@ -3866,7 +4035,11 @@ function injectBlocks(editor) {
3866
4035
  bound.set(EMPTY_API);
3867
4036
  return;
3868
4037
  }
3869
- const handler = () => {
4038
+ const handler = (payload) => {
4039
+ const within = resolveWithin();
4040
+ if (within !== null && !changeTouchesSubtree(ed, payload, within)) {
4041
+ return;
4042
+ }
3870
4043
  version.update((v) => v + 1);
3871
4044
  };
3872
4045
  ed.on(BLOCK_CHANGED_EVENT, handler);