@malva-ui/editor 0.1.14 → 0.1.15

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.
@@ -3274,13 +3274,12 @@ function insertAnchor(doc, from, to) {
3274
3274
  ? childStart(doc, to) + doc.child(to).nodeSize
3275
3275
  : childStart(doc, to);
3276
3276
  }
3277
- /** @internal Reads the live scale of the mount layer instead of trusting a CSS variable. */
3278
- function layerScale$1(mount) {
3277
+ /** @internal Measures the mount layer's frame from a single rect read. */
3278
+ function mountFrame(mount) {
3279
+ const rect = mount.getBoundingClientRect();
3279
3280
  const width = mount.offsetWidth;
3280
- if (!width)
3281
- return 1;
3282
- const scaled = mount.getBoundingClientRect().width;
3283
- return scaled > 0 ? scaled / width : 1;
3281
+ const scale = width && rect.width > 0 ? rect.width / width : 1;
3282
+ return { top: rect.top, scale };
3284
3283
  }
3285
3284
  /**
3286
3285
  * @internal Top-level child index for one rendered child of the editor DOM, or
@@ -3315,6 +3314,51 @@ function topLevelIndexOfDom(view, element) {
3315
3314
  return null;
3316
3315
  return element.contains(view.nodeDOM(childStart(doc, index))) ? index : null;
3317
3316
  }
3317
+ /**
3318
+ * @internal The hit one rendered child stands for, or null when that child is
3319
+ * not a top-level block.
3320
+ *
3321
+ * A widget decoration owns no top-level node, and its box is a sliver rather
3322
+ * than a block's. Letting one stand in for a block would pair a neighbouring
3323
+ * block's index with the widget's geometry, so `dropTargetIndex` would take
3324
+ * that block's midpoint from the wrong box and land the drop on the wrong side
3325
+ * of it — silently, in one undo step. `topLevelIndexOfDom` answering null is
3326
+ * what makes that a *category* rejection rather than a class-name test, so a
3327
+ * block-level decoration added later is covered without touching this file.
3328
+ *
3329
+ * Both edges come off one rect read, so nothing can pair a `top` from one
3330
+ * layout with a `bottom` from another.
3331
+ */
3332
+ function blockHit(view, element) {
3333
+ const index = topLevelIndexOfDom(view, element);
3334
+ if (index === null)
3335
+ return null;
3336
+ const rect = element.getBoundingClientRect();
3337
+ return { index, top: rect.top, bottom: rect.bottom };
3338
+ }
3339
+ /**
3340
+ * @internal One rendered child at or before `from` — but never before `floor` —
3341
+ * that is a top-level block, paired with its own child index. Null when that
3342
+ * span holds nothing but widgets.
3343
+ *
3344
+ * This is the only way the search below reaches a rect, and that is the point:
3345
+ * **no geometry is ever read off a child that owns no top-level node.** A
3346
+ * widget's box is a sliver where a block's is a block, and one taken out of
3347
+ * flow reports a box that says nothing about where it sits — `display: none`
3348
+ * gives an all-zero rect, and `prosemirror-gapcursor`'s shipped stylesheet
3349
+ * makes exactly that: `position: absolute; display: none`, lifted to `block`
3350
+ * only under `.ProseMirror-focused`, so a gap-cursor selection surviving a blur
3351
+ * parks an all-zero-rect child at top level. Comparing a pointer against that
3352
+ * box would drag a search past real blocks.
3353
+ */
3354
+ function blockAtOrBefore(view, children, from, floor) {
3355
+ for (let i = from; i >= floor; i -= 1) {
3356
+ const hit = blockHit(view, children[i]);
3357
+ if (hit)
3358
+ return { childIndex: i, hit };
3359
+ }
3360
+ return null;
3361
+ }
3318
3362
  /**
3319
3363
  * @internal Top-level block the given viewport y falls on, or the nearest one
3320
3364
  * above it. Null for an empty document, or for a point the view cannot map.
@@ -3327,49 +3371,85 @@ function topLevelIndexOfDom(view, element) {
3327
3371
  * test and returns null for every point outside it, in a real browser as much
3328
3372
  * as under a test DOM.
3329
3373
  *
3330
- * Rendered children lay out in document order, so the scan stops at the first
3331
- * one starting below the pointer, costing one `getBoundingClientRect()` and
3332
- * one index mapping per child down to the hovered one.
3374
+ * **Assumes the top-level blocks are vertically ordered and non-overlapping** —
3375
+ * `top` non-decreasing down the blocks, and no block's box reaching into the
3376
+ * next one's. Normal flow guarantees it for blocks, which are the only children
3377
+ * this reads a box from. Violating it (a float, `position: absolute`, a
3378
+ * negative margin on a *block*) resolves the handle to a neighbouring block
3379
+ * rather than throwing. `slotAt` assumes the same, and so did the scan this
3380
+ * replaced, which stopped at the first child starting below the pointer.
3381
+ *
3382
+ * Under that ordering the answer is *the last block starting at or above the
3383
+ * pointer*, clamped to the first block when every block starts below it. The
3384
+ * search probes child indices, but every comparison is against a **block**: a
3385
+ * probe landing on a widget resolves back to the nearest block at or before it,
3386
+ * and that block's own child index — not the widget's — bounds the next step.
3387
+ * Widget geometry therefore cannot move the search, which is the same category
3388
+ * rejection `blockHit` performs, extended to the probe itself. Pairing a
3389
+ * block's index with a widget's box is what that prevents: `dropTargetIndex`
3390
+ * would take the deciding midpoint from the wrong box and land the drop on the
3391
+ * wrong side of its neighbour — silently, in one undo step.
3333
3392
  *
3334
- * Mapping inside the loop is what makes widget decorations skippable as a
3335
- * category rather than by class name, and it is not free: `posAtDOM`,
3336
- * `nodeDOM`, and `childStart` each walk linearly to the child they address, so
3337
- * the scan down to index `k` is quadratic in `k` — measured at roughly 4x per
3338
- * doubling, against 2x for the map-only-the-winner scan this replaced. That
3339
- * one was abandoned because it could not tell a widget from a block, and
3340
- * pairing a block's index with a widget's box drops the block on the wrong
3341
- * side of its neighbour. Correctness first; the cost is only paid down to the
3342
- * *hovered* child, so it is deep hovering in a long document that degrades.
3343
- * Restoring the linear scan without losing the category skip means deferring
3344
- * the mapping until a winner is picked and walking back over the few
3345
- * consecutive widgets — the clamp above the first block is what makes that
3346
- * more than a one-liner, so it is deliberately left as follow-up.
3393
+ * Cost, against the mapping-per-child scan this replaced. `posAtDOM`, `nodeDOM`
3394
+ * and `childStart` each walk linearly to the child they address, so mapping the
3395
+ * child at index `k` is Θ(k) and scanning down to it was Θ(k²) — 500 mappings
3396
+ * and ~125k node walks for a hover at the bottom of a 500-block document, per
3397
+ * `mousemove`. Here it is Θ(log n) mappings, one per probe plus the widgets a
3398
+ * probe walks over, and ~4.5k node walks at the same size. Deferring to a
3399
+ * single mapping overall is only possible by trusting widget geometry, which
3400
+ * is the trade this deliberately refuses.
3347
3401
  */
3348
3402
  function blockAtPoint(view, clientY) {
3349
3403
  const children = view.dom.children;
3350
- let candidate = null;
3351
- for (let i = 0; i < children.length; i += 1) {
3352
- const element = children[i];
3353
- // A widget decoration owns no top-level node, and its box is a sliver
3354
- // rather than a block's. Letting one become the candidate would pair a
3355
- // neighbouring block's index with the widget's geometry, so
3356
- // `dropTargetIndex` would take that block's midpoint from the wrong box
3357
- // and land the drop on the wrong side of it — silently, in one undo step.
3358
- const index = topLevelIndexOfDom(view, element);
3359
- if (index === null)
3360
- continue;
3361
- const rect = element.getBoundingClientRect();
3362
- if (clientY < rect.top) {
3363
- // Above the first block clamps to it; in the gap between two blocks the
3364
- // preceding one was already recorded on the previous iteration.
3365
- candidate ??= { index, top: rect.top, bottom: rect.bottom };
3366
- break;
3404
+ // `high` is -1 for a view that renders no children at all, so both loops
3405
+ // below are skipped and the answer is null without a bounds check.
3406
+ let low = 0;
3407
+ let high = children.length - 1;
3408
+ let best = null;
3409
+ let bestChild = -1;
3410
+ while (low <= high) {
3411
+ const middle = (low + high) >> 1;
3412
+ const found = blockAtOrBefore(view, children, middle, low);
3413
+ if (found === null) {
3414
+ // Nothing but widgets in [low, middle]: every block still in range
3415
+ // starts after it. Discarding the span is what bounds the walk-backs.
3416
+ low = middle + 1;
3417
+ }
3418
+ else if (found.hit.top <= clientY) {
3419
+ // A candidate, and every block before it is one too; only a later block
3420
+ // can improve on it.
3421
+ best = found.hit;
3422
+ bestChild = found.childIndex;
3423
+ low = found.childIndex + 1;
3424
+ }
3425
+ else {
3426
+ high = found.childIndex - 1;
3367
3427
  }
3368
- candidate = { index, top: rect.top, bottom: rect.bottom };
3369
- if (clientY <= rect.bottom)
3370
- break;
3371
3428
  }
3372
- return candidate;
3429
+ if (best === null) {
3430
+ // Every block starts below the pointer, which is the clamp: offer the
3431
+ // first block rather than blink the handle out. Null only for a view
3432
+ // rendering no top-level block at all.
3433
+ for (let i = 0; i < children.length; i += 1) {
3434
+ const hit = blockHit(view, children[i]);
3435
+ if (hit)
3436
+ return hit;
3437
+ }
3438
+ return null;
3439
+ }
3440
+ // A pointer exactly on a block's leading edge belongs to the block above it
3441
+ // when the two boxes touch, which is where the scan this replaced ended up:
3442
+ // it broke on the first box whose `bottom` reached the pointer. The shipped
3443
+ // stylesheet keeps `0.75rem` between paragraph border boxes so nothing can
3444
+ // land here, but the answer is a silent one to change. The predecessor is
3445
+ // only resolved for a pointer exactly on `best.top`, so a well-formed
3446
+ // document pays for it on one pixel per block and never elsewhere.
3447
+ if (clientY === best.top && bestChild > 0) {
3448
+ const previous = blockAtOrBefore(view, children, bestChild - 1, 0);
3449
+ if (previous !== null && previous.hit.bottom >= clientY)
3450
+ return previous.hit;
3451
+ }
3452
+ return best;
3373
3453
  }
3374
3454
  /**
3375
3455
  * @internal Pre-move `to` index for dropping the block at `from` onto `hit`,
@@ -3405,15 +3485,16 @@ function dropTargetIndex(hit, clientY, from) {
3405
3485
  * space, while `getBoundingClientRect()` reports scaled screen pixels.
3406
3486
  */
3407
3487
  function snapshotBlocks(view, mount) {
3408
- const scale = layerScale$1(mount);
3409
- const mountTop = mount.getBoundingClientRect().top;
3488
+ const { top: mountTop, scale } = mountFrame(mount);
3410
3489
  const slots = [];
3411
3490
  const children = view.dom.children;
3412
3491
  for (let i = 0; i < children.length; i += 1) {
3413
3492
  const element = children[i];
3414
- // Same category check the hover scan uses: a widget decoration owns no
3493
+ // Same category check the hover path uses: a widget decoration owns no
3415
3494
  // top-level node and must not occupy a slot, or the partition would shift
3416
- // it as though it were a block.
3495
+ // it as though it were a block. Mapping every child once here is what the
3496
+ // drag pays instead of the hover path's deferred single mapping — the
3497
+ // partition and the settle need a slot per block regardless.
3417
3498
  const index = topLevelIndexOfDom(view, element);
3418
3499
  if (index === null)
3419
3500
  continue;
@@ -3432,9 +3513,10 @@ function snapshotBlocks(view, mount) {
3432
3513
  *
3433
3514
  * Reproduces `blockAtPoint`'s clamping exactly — above the first block resolves
3434
3515
  * to the first, a point in the gap between two blocks resolves to the
3435
- * preceding one, below the last resolves to the last — but against a static
3436
- * array, so it is a binary search rather than a scan that maps every child it
3437
- * passes.
3516
+ * preceding one, below the last resolves to the last. Both are the same
3517
+ * "last block starting at or above the pointer" search on the same ordering
3518
+ * assumption; this one runs against an array the drag already mapped, so it
3519
+ * needs no widget walk-back and reads no rect at all.
3438
3520
  */
3439
3521
  function slotAt(slots, y) {
3440
3522
  if (slots.length === 0)
@@ -3444,6 +3526,9 @@ function slotAt(slots, y) {
3444
3526
  let low = 0;
3445
3527
  let high = slots.length - 1;
3446
3528
  while (low < high) {
3529
+ // `ceil`, not `floor`: this converges by raising `low` *to* the midpoint,
3530
+ // so a `floor` midpoint of `low` for an adjacent pair would set `low` to
3531
+ // itself and spin forever.
3447
3532
  const middle = Math.ceil((low + high) / 2);
3448
3533
  if (slots[middle].top <= y)
3449
3534
  low = middle;
@@ -3674,7 +3759,30 @@ const MlvEditorBlockHandle = Extension.create({
3674
3759
  // mount is a sibling of `view.dom`, so no serializer can reach it.
3675
3760
  const indicator = createIndicatorElement();
3676
3761
  mount.appendChild(indicator);
3677
- const hide = () => handle.setAttribute('data-visible', 'false');
3762
+ /**
3763
+ * What the handle currently publishes, or null while it is
3764
+ * retracted.
3765
+ *
3766
+ * Comparing against this is what keeps a pointer move that resolves
3767
+ * an unchanged hit from touching the DOM at all, and the write it
3768
+ * saves is the smaller half of the point: an attribute write on the
3769
+ * handle dirties the layout tree, so the *next* move's rect reads
3770
+ * have to force a fresh layout before they can answer. Skipping the
3771
+ * writes leaves layout clean, and a hover that stays on one block
3772
+ * then forces no layout at all.
3773
+ *
3774
+ * Keyed on the values written rather than on `hit.index`, because
3775
+ * two of the three can change while the index does not: scrolling
3776
+ * moves the block under a stationary pointer, and `label()` is a
3777
+ * signal read that answers differently on a locale change.
3778
+ */
3779
+ let published = null;
3780
+ const hide = () => {
3781
+ if (published === null)
3782
+ return;
3783
+ published = null;
3784
+ handle.setAttribute('data-visible', 'false');
3785
+ };
3678
3786
  const onMouseMove = (event) => {
3679
3787
  if (!options.enabled())
3680
3788
  return hide();
@@ -3683,14 +3791,22 @@ const MlvEditorBlockHandle = Extension.create({
3683
3791
  return hide();
3684
3792
  // `getBoundingClientRect()` reports scaled screen pixels, but a
3685
3793
  // CSS `top` on a child of the scaled layer is applied in the
3686
- // layer's own unscaled space. Convert once, deriving the scale
3687
- // from the DOM rather than `--mlv-editor-zoom`, so the handle
3688
- // stays correct if anything else ever transforms this layer.
3689
- const scale = layerScale$1(mount);
3690
- const mountTop = mount.getBoundingClientRect().top;
3691
- handle.style.top = `${(hit.top - mountTop) / scale}px`;
3692
- handle.dataset['index'] = String(hit.index);
3693
- handle.title = options.label();
3794
+ // layer's own unscaled space. Convert once, off one rect read of
3795
+ // the mount.
3796
+ const { top: mountTop, scale } = mountFrame(mount);
3797
+ const top = `${(hit.top - mountTop) / scale}px`;
3798
+ const index = String(hit.index);
3799
+ const label = options.label();
3800
+ if (published !== null &&
3801
+ published.top === top &&
3802
+ published.index === index &&
3803
+ published.label === label) {
3804
+ return;
3805
+ }
3806
+ published = { top, index, label };
3807
+ handle.style.top = top;
3808
+ handle.dataset['index'] = index;
3809
+ handle.title = label;
3694
3810
  handle.setAttribute('data-visible', 'true');
3695
3811
  };
3696
3812
  /** Top-level index being dragged, or null when no drag is active. */
@@ -3703,8 +3819,14 @@ const MlvEditorBlockHandle = Extension.create({
3703
3819
  let ghost = null;
3704
3820
  /** Gap the partition is currently opened at, or null when closed. */
3705
3821
  let gap = null;
3706
- /** Converts a viewport y into the mount's own unscaled space. */
3707
- const toMountSpace = (clientY) => (clientY - mount.getBoundingClientRect().top) / layerScale$1(mount);
3822
+ /**
3823
+ * Converts a viewport y into the mount's own unscaled space, off
3824
+ * one rect read — this runs on every `dragover`.
3825
+ */
3826
+ const toMountSpace = (clientY) => {
3827
+ const { top, scale } = mountFrame(mount);
3828
+ return (clientY - top) / scale;
3829
+ };
3708
3830
  /**
3709
3831
  * Gap a resolved target index sits on. `dropTargetIndex` folds the
3710
3832
  * `slots.length + 1` gaps onto `moveBlock`'s pre-move convention,
@@ -3786,8 +3908,7 @@ const MlvEditorBlockHandle = Extension.create({
3786
3908
  order.splice(from, 1);
3787
3909
  order.splice(to, 0, from);
3788
3910
  const doc = view.state.doc;
3789
- const scale = layerScale$1(mount);
3790
- const mountTop = mount.getBoundingClientRect().top;
3911
+ const { top: mountTop, scale } = mountFrame(mount);
3791
3912
  const first = Math.min(from, to);
3792
3913
  const last = Math.max(from, to);
3793
3914
  for (let index = first; index <= last; index += 1) {
@@ -3849,7 +3970,7 @@ const MlvEditorBlockHandle = Extension.create({
3849
3970
  // Cloned *before* the source is dimmed: the clone carries
3850
3971
  // resolved computed styles, so dimming first would bake the
3851
3972
  // reduced opacity into the drag image.
3852
- ghost = createGhostElement(slot.element, layerScale$1(mount));
3973
+ ghost = createGhostElement(slot.element, mountFrame(mount).scale);
3853
3974
  event.dataTransfer?.setDragImage(ghost, getComputedStyle(view.dom).direction === 'rtl'
3854
3975
  ? ghost.offsetWidth
3855
3976
  : 0, 0);