tosijs-styled-editor 0.5.2 → 0.6.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.
package/dist/module.js CHANGED
@@ -329,9 +329,10 @@ function wordBoundsAt(text, offset) {
329
329
  end++;
330
330
  return { start, end };
331
331
  }
332
- function stickySelectionBounds(text, anchor, head) {
332
+ function stickySelectionBounds(text, anchor, head, leftTheAnchorWord) {
333
333
  const anchorWord = wordBoundsAt(text, anchor);
334
- if (head >= anchorWord.start && head <= anchorWord.end) {
334
+ const stillInside = leftTheAnchorWord === undefined ? head >= anchorWord.start && head <= anchorWord.end : !leftTheAnchorWord;
335
+ if (stillInside) {
335
336
  return head < anchor ? { start: head, end: anchor } : { start: anchor, end: head };
336
337
  }
337
338
  const headWord = wordBoundsAt(text, head);
@@ -421,6 +422,7 @@ class Selectable {
421
422
  root;
422
423
  selecting = false;
423
424
  touchMode = false;
425
+ stickySelection = "touch";
424
426
  constructor(root) {
425
427
  this.root = root;
426
428
  this.setup();
@@ -469,19 +471,7 @@ class Selectable {
469
471
  this.lastHovered = target;
470
472
  }
471
473
  if (this.selecting) {
472
- const hit = characterAtPoint(this.root, evt.clientX, evt.clientY);
473
- const selEnd = this.find(".sel-end");
474
- if (hit && selEnd) {
475
- if (!this.extendSticky(hit)) {
476
- const range = document.createRange();
477
- range.setStart(hit.node, hit.after ? hit.offset + 1 : hit.offset);
478
- range.collapse(true);
479
- range.insertNode(selEnd);
480
- this.root.normalize();
481
- }
482
- this.extendSelection();
483
- this.onBoundsChanged?.();
484
- }
474
+ this.extendTo(evt.clientX, evt.clientY);
485
475
  }
486
476
  evt.preventDefault();
487
477
  evt.stopPropagation();
@@ -524,9 +514,65 @@ class Selectable {
524
514
  }
525
515
  };
526
516
  }
527
- extendSticky(hit) {
517
+ setDragAnchor(hit) {
518
+ const block = hit && this.topLevelAncestor(hit.node);
519
+ this.dragAnchor = hit && block ? {
520
+ block,
521
+ index: this.textIndexOf(block).indexOf(hit.node, hit.after ? hit.offset + 1 : hit.offset) ?? 0
522
+ } : null;
523
+ }
524
+ extendTo(x, y, which = "end") {
525
+ const hit = characterAtPoint(this.root, x, y);
526
+ const marker = this.find(which === "start" ? ".sel-start" : ".sel-end");
527
+ if (!hit || !marker)
528
+ return;
529
+ if (!this.extendSticky(hit, x, y)) {
530
+ const range = document.createRange();
531
+ range.setStart(hit.node, hit.after ? hit.offset + 1 : hit.offset);
532
+ range.collapse(true);
533
+ range.insertNode(marker);
534
+ this.root.normalize();
535
+ }
536
+ this.extendSelection();
537
+ this.onBoundsChanged?.();
538
+ }
539
+ beginBoundDrag(which) {
540
+ const anchorEl = this.find(which === "start" ? ".sel-end" : ".sel-start");
541
+ const block = anchorEl && this.topLevelAncestor(anchorEl);
542
+ this.selecting = 1;
543
+ this.touchMode = true;
544
+ this.dragAnchor = anchorEl && block ? { block, index: this.textOffsetOf(block, anchorEl) } : null;
545
+ }
546
+ endBoundDrag() {
547
+ this.selecting = false;
548
+ this.dragAnchor = null;
549
+ }
550
+ pointerInAnchorWord(x, y) {
528
551
  const anchor = this.dragAnchor;
529
- if (this.selecting !== 1 || !anchor)
552
+ if (!anchor?.word)
553
+ return false;
554
+ const index = this.textIndexOf(anchor.block);
555
+ const from = index.locate(anchor.word.start);
556
+ const to = index.locate(anchor.word.end);
557
+ if (!from || !to)
558
+ return false;
559
+ const range = document.createRange();
560
+ range.setStart(from.node, Math.min(from.offset, from.node.length));
561
+ range.setEnd(to.node, Math.min(to.offset, to.node.length));
562
+ const pad = 1;
563
+ for (const r of Array.from(range.getClientRects())) {
564
+ if (x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad) {
565
+ return true;
566
+ }
567
+ }
568
+ return false;
569
+ }
570
+ get stickyApplies() {
571
+ return this.stickySelection === "always" || this.stickySelection === "touch" && this.touchMode;
572
+ }
573
+ extendSticky(hit, x, y) {
574
+ const anchor = this.dragAnchor;
575
+ if (!this.stickyApplies || this.selecting !== 1 || !anchor)
530
576
  return false;
531
577
  const block = this.topLevelAncestor(hit.node);
532
578
  if (!block || block !== anchor.block)
@@ -535,7 +581,9 @@ class Selectable {
535
581
  const head = index.indexOf(hit.node, hit.after ? hit.offset + 1 : hit.offset);
536
582
  if (head === null)
537
583
  return false;
538
- const { start, end } = stickySelectionBounds(index.text, anchor.index, head);
584
+ if (!anchor.word)
585
+ anchor.word = wordBoundsAt(index.text, anchor.index);
586
+ const { start, end } = stickySelectionBounds(index.text, anchor.index, head, !this.pointerInAnchorWord(x, y));
539
587
  const startPos = index.locate(start);
540
588
  const endPos = index.locate(end);
541
589
  if (!startPos || !endPos)
@@ -607,11 +655,7 @@ class Selectable {
607
655
  this.extendSelection();
608
656
  }
609
657
  } else if (this.selecting === 1) {
610
- const block = this.topLevelAncestor(hit.node);
611
- this.dragAnchor = block ? {
612
- block,
613
- index: this.textIndexOf(block).indexOf(hit.node, hit.after ? hit.offset + 1 : hit.offset) ?? 0
614
- } : null;
658
+ this.setDragAnchor(hit);
615
659
  this.placeCaretAt(evt.clientX, evt.clientY);
616
660
  } else if (this.selecting === 2) {
617
661
  this.selectWordAt(hit.node, hit.offset);
@@ -686,8 +730,10 @@ class Selectable {
686
730
  this.selecting = 1;
687
731
  const hit = characterAtPoint(this.root, touch.clientX, touch.clientY);
688
732
  if (hit) {
733
+ this.setDragAnchor(hit);
689
734
  this.placeCaretAt(touch.clientX, touch.clientY);
690
735
  } else if (target instanceof HTMLElement && target !== this.root) {
736
+ this.setDragAnchor(null);
691
737
  this.removeBounds();
692
738
  const bounds = this.createBounds();
693
739
  target.appendChild(bounds);
@@ -704,17 +750,7 @@ class Selectable {
704
750
  if (!target || target.closest(".not-selectable") || target.classList.contains("not-selectable")) {
705
751
  return;
706
752
  }
707
- const hit = characterAtPoint(this.root, touch.clientX, touch.clientY);
708
- const selEnd = this.find(".sel-end");
709
- if (hit && selEnd) {
710
- const range = document.createRange();
711
- range.setStart(hit.node, hit.after ? hit.offset + 1 : hit.offset);
712
- range.collapse(true);
713
- range.insertNode(selEnd);
714
- this.root.normalize();
715
- this.extendSelection();
716
- this.onBoundsChanged?.();
717
- }
753
+ this.extendTo(touch.clientX, touch.clientY);
718
754
  if (evt.cancelable)
719
755
  evt.preventDefault();
720
756
  };
@@ -723,6 +759,7 @@ class Selectable {
723
759
  this.extendSelection();
724
760
  this.normalizeBoundsOrder();
725
761
  this.selecting = false;
762
+ this.dragAnchor = null;
726
763
  }
727
764
  for (const child of Array.from(this.root.children)) {
728
765
  if (!child.classList.contains("selected-block")) {
@@ -1254,6 +1291,8 @@ function footnoteKey() {
1254
1291
  footnoteSeq += 1;
1255
1292
  return `fn-${Date.now().toString(36)}-${footnoteSeq}`;
1256
1293
  }
1294
+ var PLACEHOLDER = "Footnote text";
1295
+ var isPlaceholder = (item) => (item.textContent ?? "").trim() === PLACEHOLDER;
1257
1296
  function renumberFootnotes(root) {
1258
1297
  const refs = Array.from(root.querySelectorAll(`${FOOTNOTE_TAG}[data-footnote], .footnote-ref[data-footnote]`)).filter((ref) => !ref.closest(".footnotes"));
1259
1298
  let list = root.querySelector("ol.footnotes");
@@ -1267,29 +1306,53 @@ function renumberFootnotes(root) {
1267
1306
  root.appendChild(list);
1268
1307
  }
1269
1308
  const existing = new Map;
1309
+ const corrupt = [];
1270
1310
  for (const item of Array.from(list.children)) {
1271
1311
  const key = item.getAttribute("data-footnote");
1272
- if (key)
1312
+ if (!key)
1313
+ continue;
1314
+ const held = existing.get(key);
1315
+ if (!held) {
1316
+ existing.set(key, item);
1317
+ continue;
1318
+ }
1319
+ if (isPlaceholder(held) && !isPlaceholder(item)) {
1273
1320
  existing.set(key, item);
1321
+ corrupt.push(held);
1322
+ } else {
1323
+ corrupt.push(item);
1324
+ }
1274
1325
  }
1275
- refs.forEach((ref, index) => {
1326
+ for (const duplicate of corrupt)
1327
+ duplicate.remove();
1328
+ const numbers = new Map;
1329
+ const placed = new Set;
1330
+ for (const ref of refs) {
1276
1331
  const key = ref.getAttribute("data-footnote");
1332
+ let number = numbers.get(key);
1333
+ if (number === undefined) {
1334
+ number = numbers.size + 1;
1335
+ numbers.set(key, number);
1336
+ }
1277
1337
  const link = ref.querySelector("a");
1278
1338
  if (link) {
1279
- link.textContent = String(index + 1);
1339
+ link.textContent = String(number);
1280
1340
  link.setAttribute("href", `#${key}`);
1281
1341
  }
1342
+ if (placed.has(key))
1343
+ continue;
1344
+ placed.add(key);
1282
1345
  let item = existing.get(key);
1283
1346
  if (!item) {
1284
1347
  item = document.createElement("li");
1285
1348
  item.className = "footnote";
1286
1349
  item.setAttribute("data-footnote", key);
1287
- item.textContent = "Footnote text";
1350
+ item.textContent = PLACEHOLDER;
1288
1351
  }
1289
1352
  item.id = key;
1290
1353
  existing.delete(key);
1291
1354
  list.appendChild(item);
1292
- });
1355
+ }
1293
1356
  for (const orphan of existing.values())
1294
1357
  orphan.remove();
1295
1358
  }
@@ -1470,7 +1533,7 @@ var commands = {
1470
1533
  item.className = "footnote";
1471
1534
  item.setAttribute("data-footnote", key);
1472
1535
  item.id = key;
1473
- item.textContent = text.length ? text.join(" ") : "Footnote text";
1536
+ item.textContent = text.length ? text.join(" ") : PLACEHOLDER;
1474
1537
  list.appendChild(item);
1475
1538
  renumberFootnotes(ctx.root);
1476
1539
  ctx.normalize();
@@ -1708,6 +1771,8 @@ import { icons as icons2 } from "tosijs-ui/icons";
1708
1771
  // src/changes.ts
1709
1772
  var INS_TAG = "tosi-ins";
1710
1773
  var DEL_TAG = "tosi-del";
1774
+ var BLOCK_DELETE_ATTR = "data-block-delete";
1775
+ var BLOCK_INSERT_ATTR = "data-block-insert";
1711
1776
  var changeSeq = 0;
1712
1777
  var changeId = () => `chg-${Date.now().toString(36)}-${(changeSeq++).toString(36)}`;
1713
1778
 
@@ -1727,14 +1792,20 @@ function defineChanges() {
1727
1792
  function safeAttributeValue(value) {
1728
1793
  return value.replace(/[<>]/g, "");
1729
1794
  }
1730
- function mark(kind, text, author) {
1731
- const el = document.createElement(kind === "insert" ? INS_TAG : DEL_TAG);
1732
- el.setAttribute("data-change", changeId());
1733
- el.setAttribute("data-author", safeAttributeValue(author.id));
1734
- if (author.name) {
1735
- el.setAttribute("data-author-name", safeAttributeValue(author.name));
1736
- }
1795
+ function stampMark(el, attribution) {
1796
+ const id = attribution.id ?? changeId();
1797
+ el.setAttribute("data-change", id);
1798
+ el.setAttribute("data-author", safeAttributeValue(attribution.author.id));
1799
+ if (attribution.author.name) {
1800
+ el.setAttribute("data-author-name", safeAttributeValue(attribution.author.name));
1801
+ }
1802
+ el.setAttribute("data-session", safeAttributeValue(attribution.session));
1737
1803
  el.setAttribute("data-time", new Date().toISOString());
1804
+ return id;
1805
+ }
1806
+ function mark(kind, text, author, session) {
1807
+ const el = document.createElement(kind === "insert" ? INS_TAG : DEL_TAG);
1808
+ stampMark(el, { author, session });
1738
1809
  el.textContent = text;
1739
1810
  return el;
1740
1811
  }
@@ -1821,7 +1892,7 @@ function diffWords(before, after) {
1821
1892
  push("insert", b[j++]);
1822
1893
  return ops;
1823
1894
  }
1824
- function applyRevision(node, revised, author) {
1895
+ function applyRevision(node, revised, author, session) {
1825
1896
  const ops = diffWords(node.data, revised);
1826
1897
  if (!ops.some((o) => o.op !== "same"))
1827
1898
  return 0;
@@ -1832,7 +1903,7 @@ function applyRevision(node, revised, author) {
1832
1903
  if (op.op === "same") {
1833
1904
  fragment.appendChild(document.createTextNode(op.text));
1834
1905
  } else {
1835
- fragment.appendChild(mark(op.op, op.text, author));
1906
+ fragment.appendChild(mark(op.op, op.text, author, session));
1836
1907
  changes++;
1837
1908
  }
1838
1909
  }
@@ -2245,6 +2316,35 @@ function deletableFilter(node) {
2245
2316
  }
2246
2317
  return node.nodeType !== 3 || node.textContent !== "";
2247
2318
  }
2319
+ var MERGEABLE_BLOCKS = new Set([
2320
+ "p",
2321
+ "div",
2322
+ "h1",
2323
+ "h2",
2324
+ "h3",
2325
+ "h4",
2326
+ "h5",
2327
+ "h6",
2328
+ "blockquote",
2329
+ "pre",
2330
+ "figure",
2331
+ "figcaption",
2332
+ "address",
2333
+ "section",
2334
+ "article",
2335
+ "aside",
2336
+ "main",
2337
+ "header",
2338
+ "footer"
2339
+ ]);
2340
+ var AFFORDANCE_SIZE = 44;
2341
+ function menuAffordanceX(startX, endX, size, maxX) {
2342
+ const centred = (startX + endX) / 2 - size / 2;
2343
+ let x = Math.max(centred, startX);
2344
+ if (x + size > maxX)
2345
+ x = startX - size * 2;
2346
+ return Math.max(x, 0);
2347
+ }
2248
2348
 
2249
2349
  class TosijsStyledEditor extends WebComponent {
2250
2350
  static formAssociated = true;
@@ -2491,8 +2591,8 @@ class TosijsStyledEditor extends WebComponent {
2491
2591
  },
2492
2592
  ":host .touch-affordance": {
2493
2593
  position: "absolute",
2494
- width: "44px",
2495
- height: "44px",
2594
+ width: `${AFFORDANCE_SIZE}px`,
2595
+ height: `${AFFORDANCE_SIZE}px`,
2496
2596
  display: "flex",
2497
2597
  alignItems: "center",
2498
2598
  justifyContent: "center",
@@ -2699,7 +2799,7 @@ class TosijsStyledEditor extends WebComponent {
2699
2799
  const revised = await revise(text.data);
2700
2800
  if (typeof revised !== "string")
2701
2801
  continue;
2702
- total += applyRevision(text, revised, author);
2802
+ total += applyRevision(text, revised, author, this.sessionId);
2703
2803
  }
2704
2804
  } finally {
2705
2805
  if (total > 0) {
@@ -2710,6 +2810,16 @@ class TosijsStyledEditor extends WebComponent {
2710
2810
  return total;
2711
2811
  }
2712
2812
  trackChanges = false;
2813
+ trackStructuralEdits = false;
2814
+ get stickySelection() {
2815
+ return this.selectable?.stickySelection ?? this.pendingStickySelection;
2816
+ }
2817
+ set stickySelection(mode) {
2818
+ this.pendingStickySelection = mode;
2819
+ if (this.selectable)
2820
+ this.selectable.stickySelection = mode;
2821
+ }
2822
+ pendingStickySelection = "touch";
2713
2823
  sessionId = `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2714
2824
  currentInsertion() {
2715
2825
  const ip = this.insertionPoint();
@@ -2740,13 +2850,7 @@ class TosijsStyledEditor extends WebComponent {
2740
2850
  return null;
2741
2851
  defineChanges();
2742
2852
  const ins = document.createElement(INS_TAG);
2743
- ins.setAttribute("data-change", changeId());
2744
- ins.setAttribute("data-author", safeAttributeValue(this.changeAuthor.id));
2745
- if (this.changeAuthor.name) {
2746
- ins.setAttribute("data-author-name", safeAttributeValue(this.changeAuthor.name));
2747
- }
2748
- ins.setAttribute("data-session", this.sessionId);
2749
- ins.setAttribute("data-time", new Date().toISOString());
2853
+ this.stamp(ins);
2750
2854
  const enclosing = ip.closest(INS_TAG);
2751
2855
  if (!enclosing?.parentNode) {
2752
2856
  ip.before(ins);
@@ -2782,19 +2886,12 @@ class TosijsStyledEditor extends WebComponent {
2782
2886
  const asBlock = node.nodeType === 1 && node.parentNode === this.parts.doc ? node : null;
2783
2887
  if (asBlock && !asBlock.firstChild)
2784
2888
  continue;
2785
- if (asBlock?.firstElementChild?.matches?.(DEL_TAG) && asBlock.children.length === 1) {
2889
+ if (asBlock && this.isFullyStruck(asBlock))
2786
2890
  continue;
2787
- }
2788
2891
  const del = document.createElement(DEL_TAG);
2789
- del.setAttribute("data-change", id);
2790
- del.setAttribute("data-author", safeAttributeValue(this.changeAuthor.id));
2791
- if (this.changeAuthor.name) {
2792
- del.setAttribute("data-author-name", safeAttributeValue(this.changeAuthor.name));
2793
- }
2794
- del.setAttribute("data-session", this.sessionId);
2795
- del.setAttribute("data-time", new Date().toISOString());
2892
+ this.stamp(del, id);
2796
2893
  if (asBlock) {
2797
- del.setAttribute("data-block-delete", "");
2894
+ del.setAttribute(BLOCK_DELETE_ATTR, "");
2798
2895
  while (asBlock.firstChild)
2799
2896
  del.appendChild(asBlock.firstChild);
2800
2897
  asBlock.appendChild(del);
@@ -2838,15 +2935,78 @@ class TosijsStyledEditor extends WebComponent {
2838
2935
  get refusesStructuralDelete() {
2839
2936
  return this.trackChanges;
2840
2937
  }
2841
- asUntracked(fn) {
2842
- const was = this.trackChanges;
2843
- this.trackChanges = false;
2844
- try {
2845
- return fn();
2846
- } finally {
2847
- this.trackChanges = was;
2938
+ rescueCaretFrom(block) {
2939
+ const markers = Array.from(block.querySelectorAll(".sel-start, .sel-end, .caret"));
2940
+ if (!markers.length)
2941
+ return;
2942
+ let top = block;
2943
+ while (top.parentElement && top.parentElement !== this.parts.doc) {
2944
+ top = top.parentElement;
2945
+ }
2946
+ const host = this.siblingBlock(top, "back") ?? this.siblingBlock(top, "forward");
2947
+ for (const marker of markers) {
2948
+ if (host)
2949
+ host.appendChild(marker);
2950
+ else
2951
+ this.parts.doc.appendChild(marker);
2848
2952
  }
2849
2953
  }
2954
+ isFullyStruck(block) {
2955
+ const probe = document.createElement("div");
2956
+ for (const child of Array.from(block.childNodes)) {
2957
+ probe.appendChild(child.cloneNode(true));
2958
+ }
2959
+ for (const el of Array.from(probe.querySelectorAll(DEL_TAG)))
2960
+ el.remove();
2961
+ for (const el of Array.from(probe.querySelectorAll(".sel-start, .sel-end, .caret"))) {
2962
+ el.remove();
2963
+ }
2964
+ return blockIsEmpty(probe);
2965
+ }
2966
+ resolvedClone(block) {
2967
+ const frag = document.createDocumentFragment();
2968
+ for (const child of Array.from(block.childNodes)) {
2969
+ frag.appendChild(child.cloneNode(true));
2970
+ }
2971
+ for (const el of Array.from(frag.querySelectorAll(".sel-start, .sel-end, .caret"))) {
2972
+ el.remove();
2973
+ }
2974
+ for (const del of Array.from(frag.querySelectorAll(DEL_TAG)))
2975
+ del.remove();
2976
+ for (const ins of Array.from(frag.querySelectorAll(INS_TAG)))
2977
+ unwrap(ins);
2978
+ return frag;
2979
+ }
2980
+ blockLike(model) {
2981
+ const block = model.cloneNode(false);
2982
+ block.classList.remove("selected-block", "first-block", "last-block", "selected");
2983
+ if (!block.getAttribute("class"))
2984
+ block.removeAttribute("class");
2985
+ return block;
2986
+ }
2987
+ canMergeBlocks(blocks) {
2988
+ return blocks.every((b) => !!b && MERGEABLE_BLOCKS.has(b.localName) && !b.classList.contains("editor-table"));
2989
+ }
2990
+ trackStructuralEdit(blocksOut, gestureId) {
2991
+ defineChanges();
2992
+ const id = gestureId ?? changeId();
2993
+ const ins = document.createElement(INS_TAG);
2994
+ this.stamp(ins, id);
2995
+ ins.setAttribute(BLOCK_INSERT_ATTR, "");
2996
+ const caret = this.selectable.createBounds();
2997
+ const parts = blocksOut.map((b) => this.resolvedClone(b));
2998
+ const merged = this.blockLike(blocksOut[0]);
2999
+ parts.forEach((frag, i) => {
3000
+ if (i === 1)
3001
+ ins.appendChild(caret);
3002
+ ins.appendChild(frag);
3003
+ });
3004
+ merged.appendChild(ins);
3005
+ this.selectable.removeBounds();
3006
+ this.trackDeletion(blocksOut, id);
3007
+ blocksOut[blocksOut.length - 1].after(merged);
3008
+ return { ins, caret };
3009
+ }
2850
3010
  refuseStructural(reason) {
2851
3011
  const evt = new CustomEvent("structural-edit-refused", {
2852
3012
  bubbles: true,
@@ -2856,6 +3016,13 @@ class TosijsStyledEditor extends WebComponent {
2856
3016
  });
2857
3017
  return this.dispatchEvent(evt);
2858
3018
  }
3019
+ stamp(el, id) {
3020
+ return stampMark(el, {
3021
+ id,
3022
+ author: this.changeAuthor,
3023
+ session: this.sessionId
3024
+ });
3025
+ }
2859
3026
  get changes() {
2860
3027
  return changesIn(this.parts.doc);
2861
3028
  }
@@ -2863,7 +3030,7 @@ class TosijsStyledEditor extends WebComponent {
2863
3030
  if (id === "")
2864
3031
  return;
2865
3032
  const targets = this.changes.filter((c) => !id || c.id === id);
2866
- const emptied = targets.filter((c) => c.kind === "delete" && c.element.hasAttribute("data-block-delete")).map((c) => c.element.parentElement).filter((el) => !!el && el.parentNode === this.parts.doc);
3033
+ const emptied = this.blocksOwnedByMarks(targets, "delete", BLOCK_DELETE_ATTR);
2867
3034
  for (const change of targets)
2868
3035
  acceptChange(change.element);
2869
3036
  for (const block of emptied) {
@@ -2876,12 +3043,31 @@ class TosijsStyledEditor extends WebComponent {
2876
3043
  this.updateUndo("new", "accept-changes");
2877
3044
  }
2878
3045
  }
3046
+ blocksOwnedByMarks(targets, kind, attr) {
3047
+ const blocks = targets.filter((c) => c.kind === kind && c.element.hasAttribute(attr)).map((c) => this.block(c.element)).filter((el) => el instanceof HTMLElement);
3048
+ return [...new Set(blocks)];
3049
+ }
2879
3050
  rejectChanges(id) {
2880
3051
  if (id === "")
2881
3052
  return;
2882
3053
  const targets = this.changes.filter((c) => !id || c.id === id);
3054
+ const proposed = this.blocksOwnedByMarks(targets, "insert", BLOCK_INSERT_ATTR);
3055
+ for (const change of targets) {
3056
+ if (change.kind !== "insert")
3057
+ continue;
3058
+ const mark = change.element;
3059
+ for (const marker of Array.from(mark.querySelectorAll(".sel-start, .sel-end, .caret"))) {
3060
+ mark.parentNode?.insertBefore(marker, mark);
3061
+ }
3062
+ }
2883
3063
  for (const change of targets)
2884
3064
  rejectChange(change.element);
3065
+ for (const block of proposed) {
3066
+ if (!block.isConnected || !blockIsEmpty(block))
3067
+ continue;
3068
+ this.rescueCaretFrom(block);
3069
+ block.remove();
3070
+ }
2885
3071
  if (targets.length) {
2886
3072
  this.normalize();
2887
3073
  this.updateUndo("new", "reject-changes");
@@ -2987,6 +3173,7 @@ class TosijsStyledEditor extends WebComponent {
2987
3173
  }
2988
3174
  this.isInitialized = true;
2989
3175
  this.selectable = new Selectable(doc);
3176
+ this.selectable.stickySelection = this.pendingStickySelection;
2990
3177
  const firstP = doc.querySelector("p,h1,h2,h3,h4,h5,h6,div,blockquote,pre") || doc.firstElementChild;
2991
3178
  if (firstP) {
2992
3179
  firstP.appendChild(this.selectable.createBounds());
@@ -3627,6 +3814,42 @@ class TosijsStyledEditor extends WebComponent {
3627
3814
  isolate.appendChild(ip);
3628
3815
  return isolate;
3629
3816
  }
3817
+ refuseIfUnmergeable(a, b) {
3818
+ if (!a || !b || a === b)
3819
+ return false;
3820
+ if (this.canMergeBlocks([a, b]))
3821
+ return false;
3822
+ this.refuseMerge();
3823
+ return true;
3824
+ }
3825
+ refuseMerge() {
3826
+ this.refuseStructural("merge-blocks-not-mergeable");
3827
+ }
3828
+ mergeBlocksRaw(earlier, later) {
3829
+ while (later.firstChild)
3830
+ earlier.appendChild(later.firstChild);
3831
+ later.remove();
3832
+ }
3833
+ mayTrackStructurally(reason) {
3834
+ if (this.trackStructuralEdits)
3835
+ return true;
3836
+ this.refuseStructural(reason);
3837
+ return false;
3838
+ }
3839
+ mergeBlocksTracked(earlier, later) {
3840
+ this.trackStructuralEdit([earlier, later]);
3841
+ this.normalize();
3842
+ this.focus();
3843
+ this.updateUndo("new", "merge-blocks");
3844
+ }
3845
+ siblingBlock(block, direction) {
3846
+ const step = (el) => direction === "back" ? el.previousElementSibling : el.nextElementSibling;
3847
+ let el = step(block);
3848
+ while (el && (el.classList.contains("not-selectable") || el.classList.contains("do-not-spanify"))) {
3849
+ el = step(el);
3850
+ }
3851
+ return el;
3852
+ }
3630
3853
  backspace() {
3631
3854
  if (!this.deleteSelection()) {
3632
3855
  const ip = this.insertionPoint();
@@ -3638,25 +3861,22 @@ class TosijsStyledEditor extends WebComponent {
3638
3861
  const caretBlock = this.block(ip);
3639
3862
  const deletionBlock = this.block(node);
3640
3863
  const crossesBlocks = !!(deletionBlock && caretBlock && deletionBlock !== caretBlock);
3641
- if (this.refusesStructuralDelete && crossesBlocks && this.refuseStructural("merge-blocks-backward")) {
3864
+ if (this.refuseIfUnmergeable(deletionBlock, caretBlock))
3865
+ return;
3866
+ if (this.trackChanges && crossesBlocks) {
3867
+ if (!this.mayTrackStructurally("merge-blocks-backward"))
3868
+ return;
3869
+ this.mergeBlocksTracked(deletionBlock, caretBlock);
3642
3870
  return;
3643
3871
  }
3644
- const overridden = this.refusesStructuralDelete && crossesBlocks;
3645
- const run = (fn) => overridden ? this.asUntracked(fn) : fn();
3646
- run(() => {
3647
- if (node.nodeType === 3 && (node.textContent || "").length > 1) {
3648
- this.deleteEdgeCharacter(node, "end");
3649
- } else {
3650
- this.removeNode(topSingleParentAncestor(node));
3651
- }
3652
- this.normalize();
3653
- if (deletionBlock && caretBlock && this.parts.doc.contains(deletionBlock) && deletionBlock !== caretBlock) {
3654
- while (caretBlock.firstChild) {
3655
- deletionBlock.appendChild(caretBlock.firstChild);
3656
- }
3657
- caretBlock.remove();
3658
- }
3659
- });
3872
+ if (crossesBlocks) {
3873
+ this.mergeBlocksRaw(deletionBlock, caretBlock);
3874
+ } else if (node.nodeType === 3 && (node.textContent || "").length > 1) {
3875
+ this.deleteEdgeCharacter(node, "end");
3876
+ } else {
3877
+ this.removeNode(topSingleParentAncestor(node));
3878
+ }
3879
+ this.normalize();
3660
3880
  this.updateUndo();
3661
3881
  }
3662
3882
  }
@@ -3665,42 +3885,66 @@ class TosijsStyledEditor extends WebComponent {
3665
3885
  const ip = this.insertionPoint();
3666
3886
  if (!ip)
3667
3887
  return;
3888
+ const emptyBlock = this.block(ip);
3889
+ const intoPrevious = emptyBlock && blockIsEmpty(emptyBlock) ? this.siblingBlock(emptyBlock, "back") : null;
3890
+ if (emptyBlock && intoPrevious) {
3891
+ if (this.refuseIfUnmergeable(intoPrevious, emptyBlock))
3892
+ return;
3893
+ if (this.trackChanges) {
3894
+ if (!this.mayTrackStructurally("merge-blocks-forward"))
3895
+ return;
3896
+ this.mergeBlocksTracked(intoPrevious, emptyBlock);
3897
+ } else {
3898
+ this.mergeBlocksRaw(intoPrevious, emptyBlock);
3899
+ this.normalize();
3900
+ this.updateUndo();
3901
+ }
3902
+ return;
3903
+ }
3668
3904
  const node = this.deletionTarget(ip, this.parts.doc, "forward");
3669
3905
  if (!node)
3670
3906
  return;
3671
3907
  const caretBlock = this.block(ip);
3672
3908
  const deletionBlock = this.block(node);
3909
+ if (this.refuseIfUnmergeable(caretBlock, deletionBlock))
3910
+ return;
3673
3911
  const crossesBlocks = !!(deletionBlock && caretBlock && deletionBlock !== caretBlock);
3674
- if (this.refusesStructuralDelete && crossesBlocks && this.refuseStructural("merge-blocks-forward")) {
3912
+ if (this.trackChanges && crossesBlocks) {
3913
+ if (!this.mayTrackStructurally("merge-blocks-forward"))
3914
+ return;
3915
+ this.mergeBlocksTracked(caretBlock, deletionBlock);
3675
3916
  return;
3676
3917
  }
3677
- const overridden = this.refusesStructuralDelete && crossesBlocks;
3678
- const run = (fn) => overridden ? this.asUntracked(fn) : fn();
3679
- run(() => {
3680
- if (node.nodeType === 3 && (node.textContent || "").length > 1) {
3681
- this.deleteEdgeCharacter(node, "start");
3682
- } else {
3683
- this.removeNode(topSingleParentAncestor(node));
3684
- }
3685
- this.normalize();
3686
- if (deletionBlock && caretBlock && this.parts.doc.contains(deletionBlock) && deletionBlock !== caretBlock) {
3687
- while (deletionBlock.firstChild) {
3688
- caretBlock.appendChild(deletionBlock.firstChild);
3689
- }
3690
- deletionBlock.remove();
3691
- }
3692
- });
3918
+ if (crossesBlocks) {
3919
+ this.mergeBlocksRaw(caretBlock, deletionBlock);
3920
+ } else if (node.nodeType === 3 && (node.textContent || "").length > 1) {
3921
+ this.deleteEdgeCharacter(node, "start");
3922
+ } else {
3923
+ this.removeNode(topSingleParentAncestor(node));
3924
+ }
3925
+ this.normalize();
3693
3926
  this.updateUndo();
3694
3927
  }
3695
3928
  }
3696
3929
  deleteSelection() {
3697
3930
  const blocks = this.selectedBlocks();
3698
3931
  const crossesBlocks = blocks.length > 1;
3699
- const refusing = this.refusesStructuralDelete && crossesBlocks;
3700
- const overridden = refusing && !this.refuseStructural("merge-blocks-selection");
3701
- return overridden ? this.asUntracked(() => this.runDeleteSelection(blocks, true)) : this.runDeleteSelection(blocks, !refusing);
3932
+ const ends = crossesBlocks ? [blocks[0], blocks[blocks.length - 1]] : null;
3933
+ const unmergeable = !!ends && !this.canMergeBlocks(ends);
3934
+ if (unmergeable)
3935
+ this.refuseMerge();
3936
+ if (!this.trackChanges || !crossesBlocks) {
3937
+ return this.runDeleteSelection(blocks, "raw");
3938
+ }
3939
+ if (unmergeable) {
3940
+ return this.runDeleteSelection(blocks, "none");
3941
+ }
3942
+ if (!this.mayTrackStructurally("merge-blocks-selection")) {
3943
+ return this.runDeleteSelection(blocks, "none");
3944
+ }
3945
+ return this.runDeleteSelection(blocks, "tracked");
3702
3946
  }
3703
- runDeleteSelection(blocks, mayMerge) {
3947
+ runDeleteSelection(blocks, merge) {
3704
3948
  let wasAnythingDeleted = false;
3705
3949
  const gesture = this.trackChanges ? changeId() : undefined;
3706
3950
  for (const block of blocks) {
@@ -3735,12 +3979,11 @@ class TosijsStyledEditor extends WebComponent {
3735
3979
  if (blocks.length > 1) {
3736
3980
  const firstBlock = blocks[0];
3737
3981
  const lastBlock = blocks[blocks.length - 1];
3738
- if (mayMerge && this.parts.doc.contains(firstBlock) && this.parts.doc.contains(lastBlock) && !firstBlock.classList.contains("editor-table") && !lastBlock.classList.contains("editor-table")) {
3739
- const anchor = lastBlock.firstChild;
3740
- for (const node of Array.from(firstBlock.childNodes)) {
3741
- lastBlock.insertBefore(node, anchor);
3742
- }
3743
- firstBlock.remove();
3982
+ const mergeable = this.parts.doc.contains(firstBlock) && this.parts.doc.contains(lastBlock) && this.canMergeBlocks([firstBlock, lastBlock]);
3983
+ if (mergeable && merge === "tracked") {
3984
+ this.trackStructuralEdit([firstBlock, lastBlock], gesture);
3985
+ } else if (mergeable && merge === "raw") {
3986
+ this.mergeBlocksRaw(firstBlock, lastBlock);
3744
3987
  }
3745
3988
  this.selectable.markBounds();
3746
3989
  this.focus();
@@ -4266,16 +4509,11 @@ class TosijsStyledEditor extends WebComponent {
4266
4509
  return;
4267
4510
  const id = changeId();
4268
4511
  for (const mark of Array.from(marks)) {
4269
- mark.setAttribute("data-change", id);
4270
- mark.setAttribute("data-author", safeAttributeValue(this.changeAuthor.id));
4271
- if (this.changeAuthor.name) {
4272
- mark.setAttribute("data-author-name", safeAttributeValue(this.changeAuthor.name));
4273
- } else {
4512
+ if (!this.changeAuthor.name)
4274
4513
  mark.removeAttribute("data-author-name");
4275
- }
4276
- mark.setAttribute("data-session", this.sessionId);
4277
- mark.setAttribute("data-time", new Date().toISOString());
4278
- mark.removeAttribute("data-block-delete");
4514
+ this.stamp(mark, id);
4515
+ mark.removeAttribute(BLOCK_DELETE_ATTR);
4516
+ mark.removeAttribute(BLOCK_INSERT_ATTR);
4279
4517
  }
4280
4518
  }
4281
4519
  handlePaste = (evt) => {
@@ -4440,24 +4678,14 @@ class TosijsStyledEditor extends WebComponent {
4440
4678
  const startY = startRect.top - docRect.top + this.parts.doc.scrollTop;
4441
4679
  const endX = endRect.right - docRect.left + this.parts.doc.scrollLeft;
4442
4680
  const endY = endRect.bottom - docRect.top + this.parts.doc.scrollTop;
4443
- const isCollapsed = selStart.nextElementSibling === selEnd;
4444
- if (isCollapsed) {
4445
- this.touchHandleStart.style.left = `${startX - 44}px`;
4446
- this.touchHandleStart.style.top = `${startY - 44}px`;
4447
- this.touchContextMenu.style.left = `${startX}px`;
4448
- this.touchContextMenu.style.top = `${startY - 44}px`;
4449
- this.touchHandleEnd.style.left = `${endX}px`;
4450
- this.touchHandleEnd.style.top = `${endY}px`;
4451
- } else {
4452
- this.touchHandleStart.style.left = `${startX - 44}px`;
4453
- this.touchHandleStart.style.top = `${startY - 44}px`;
4454
- this.touchHandleEnd.style.left = `${endX}px`;
4455
- this.touchHandleEnd.style.top = `${endY}px`;
4456
- const midX = (startX + endX) / 2 - 22;
4457
- const topY = Math.min(startY, endY) - 44;
4458
- this.touchContextMenu.style.left = `${midX}px`;
4459
- this.touchContextMenu.style.top = `${topY}px`;
4460
- }
4681
+ const size = AFFORDANCE_SIZE;
4682
+ this.touchHandleStart.style.left = `${startX - size}px`;
4683
+ this.touchHandleStart.style.top = `${startY - size}px`;
4684
+ this.touchHandleEnd.style.left = `${endX}px`;
4685
+ this.touchHandleEnd.style.top = `${endY}px`;
4686
+ const maxX = this.parts.doc.clientWidth + this.parts.doc.scrollLeft;
4687
+ this.touchContextMenu.style.left = `${menuAffordanceX(startX, endX, size, maxX)}px`;
4688
+ this.touchContextMenu.style.top = `${Math.min(startY, endY) - size}px`;
4461
4689
  }
4462
4690
  handleAffordanceDragStart = (evt) => {
4463
4691
  const handle = evt.currentTarget;
@@ -4482,6 +4710,7 @@ class TosijsStyledEditor extends WebComponent {
4482
4710
  offsetX,
4483
4711
  offsetY
4484
4712
  });
4713
+ this.selectable.beginBoundDrag(which);
4485
4714
  this.hideTouchMenu();
4486
4715
  this.touchAffordances.classList.add("dragging");
4487
4716
  evt.preventDefault();
@@ -4502,25 +4731,14 @@ class TosijsStyledEditor extends WebComponent {
4502
4731
  if (!elementAtPoint || elementAtPoint.closest(".not-selectable") || elementAtPoint.classList.contains("not-selectable")) {
4503
4732
  return;
4504
4733
  }
4505
- const hit = characterAtPoint(this.parts.doc, cursorX, cursorY);
4506
- if (!hit)
4507
- return;
4508
- const markerSelector = drag.target === "start" ? ".sel-start" : ".sel-end";
4509
- const marker = this.parts.doc.querySelector(markerSelector);
4510
- if (marker) {
4511
- const range = document.createRange();
4512
- range.setStart(hit.node, hit.after ? hit.offset + 1 : hit.offset);
4513
- range.collapse(true);
4514
- range.insertNode(marker);
4515
- this.parts.doc.normalize();
4516
- this.selectable.markBounds();
4517
- const docRect = this.parts.doc.getBoundingClientRect();
4518
- const handle = evt.currentTarget;
4519
- const handleX = evt.clientX - docRect.left + this.parts.doc.scrollLeft;
4520
- const handleY = evt.clientY - docRect.top + this.parts.doc.scrollTop;
4521
- handle.style.left = `${handleX - 22}px`;
4522
- handle.style.top = `${handleY - 22}px`;
4523
- }
4734
+ this.selectable.extendTo(cursorX, cursorY, drag.target);
4735
+ this.selectable.markBounds();
4736
+ const docRect = this.parts.doc.getBoundingClientRect();
4737
+ const handle = evt.currentTarget;
4738
+ const handleX = evt.clientX - docRect.left + this.parts.doc.scrollLeft;
4739
+ const handleY = evt.clientY - docRect.top + this.parts.doc.scrollTop;
4740
+ handle.style.left = `${handleX - AFFORDANCE_SIZE / 2}px`;
4741
+ handle.style.top = `${handleY - AFFORDANCE_SIZE / 2}px`;
4524
4742
  evt.preventDefault();
4525
4743
  evt.stopPropagation();
4526
4744
  };
@@ -4531,6 +4749,7 @@ class TosijsStyledEditor extends WebComponent {
4531
4749
  handle.releasePointerCapture(evt.pointerId);
4532
4750
  this.touchDrags.delete(evt.pointerId);
4533
4751
  if (this.touchDrags.size === 0) {
4752
+ this.selectable.endBoundDrag();
4534
4753
  this.selectable.despanify();
4535
4754
  this.selectable.selectionChanged();
4536
4755
  requestAnimationFrame(() => {
@@ -4734,8 +4953,11 @@ class TosijsStyledEditor extends WebComponent {
4734
4953
  defineFootnote();
4735
4954
  var tosijsStyledEditor = TosijsStyledEditor.elementCreator();
4736
4955
  // src/version.ts
4737
- var version = "0.5.2";
4956
+ var version = "0.6.0";
4738
4957
  export {
4958
+ AFFORDANCE_SIZE,
4959
+ BLOCK_DELETE_ATTR,
4960
+ BLOCK_INSERT_ATTR,
4739
4961
  DEL_TAG,
4740
4962
  FOOTNOTE_TAG,
4741
4963
  INS_TAG,
@@ -4799,6 +5021,7 @@ export {
4799
5021
  localePickerWidget,
4800
5022
  makeCSS,
4801
5023
  makeFilter,
5024
+ menuAffordanceX,
4802
5025
  minimalToolbar,
4803
5026
  nextCell,
4804
5027
  nextLeafNode,
@@ -4815,6 +5038,7 @@ export {
4815
5038
  setColumnWidths,
4816
5039
  siblingOrder,
4817
5040
  spanify,
5041
+ stampMark,
4818
5042
  stickySelectionBounds,
4819
5043
  tableMenu,
4820
5044
  tableOf,
@@ -4823,6 +5047,7 @@ export {
4823
5047
  topSingleParentAncestor,
4824
5048
  tosijsStyledEditor,
4825
5049
  undoRedoWidgets,
5050
+ unwrap,
4826
5051
  version,
4827
5052
  wordsIn
4828
5053
  };