@37signals/lexxy 0.9.9-beta → 0.9.9-beta.preview2

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/lexxy.esm.js CHANGED
@@ -1,19 +1,19 @@
1
1
  import { isActiveAndVisible, createElement, extractPlainTextFromHtml, createAttachmentFigure, isPreviewableImage, dispatch, parseHtml, addBlockSpacing, generateDomId } from './lexxy_helpers.esm.js';
2
2
  export { highlightCode } from './lexxy_helpers.esm.js';
3
3
  import DOMPurify from 'dompurify';
4
- import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType, $forEachSelectedTextNode, $ensureForwardRangeSelection } from '@lexical/selection';
5
- import { SKIP_DOM_SELECTION_TAG, $getSelection, $isRangeSelection, DecoratorNode, $createParagraphNode, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, $createTextNode, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, $isElementNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, COMMAND_PRIORITY_LOW, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getEditor, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, ParagraphNode, $isRootOrShadowRoot, ElementNode, $splitNode, $isParagraphNode, $createLineBreakNode, $isRootNode, $getChildCaretAtIndex, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, CLEAR_HISTORY_COMMAND, $addUpdateTag, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
4
+ import { getStyleObjectFromCSS, getCSSFromStyleObject, $isAtNodeEnd, $getSelectionStyleValueForProperty, $patchStyleText, $ensureForwardRangeSelection, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
5
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, DecoratorNode, $createParagraphNode, $getNodeByKey, $isTextNode, $createRangeSelection, $setSelection, $createTextNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $createNodeSelection, $isDecoratorNode, $isLineBreakNode, TextNode, createCommand, createState, defineExtension, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, PASTE_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, COMMAND_PRIORITY_EDITOR, $getEditor, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $isNodeSelection, $getRoot, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $addUpdateTag, ElementNode, $splitNode, $getChildCaretAtIndex, $createLineBreakNode, $isParagraphNode, ParagraphNode, RootNode, COMMAND_PRIORITY_HIGH, DRAGSTART_COMMAND, DROP_COMMAND, INSERT_PARAGRAPH_COMMAND, mergeRegister as mergeRegister$1, $findMatchingParent, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, COMMAND_PRIORITY_CRITICAL, KEY_SPACE_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND } from 'lexical';
6
6
  import { buildEditorFromExtensions } from '@lexical/extension';
7
7
  import { ListNode, ListItemNode, $getListDepth, INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, $isListItemNode, $isListNode, registerList } from '@lexical/list';
8
- import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, AutoLinkNode, $isLinkNode } from '@lexical/link';
8
+ import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
9
9
  import { $getNearestNodeOfType, $wrapNodeInElement, $lastToFirstIterator, mergeRegister, $insertFirst, $unwrapAndFilterDescendants, $firstToLastIterator, $getNearestBlockElementAncestorOrThrow, $descendantsMatching, IS_APPLE } from '@lexical/utils';
10
10
  import { registerPlainText } from '@lexical/plain-text';
11
- import { RichTextExtension, $isQuoteNode, $isHeadingNode, $createHeadingNode, $createQuoteNode, QuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
11
+ import { RichTextExtension, $isQuoteNode, $isHeadingNode, QuoteNode, $createHeadingNode, $createQuoteNode, HeadingNode, registerRichText } from '@lexical/rich-text';
12
12
  import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
13
13
  import { $isCodeNode, CodeHighlightNode, CodeNode, $createCodeNode, $isCodeHighlightNode, $createCodeHighlightNode, normalizeCodeLang, registerCodeHighlighting, CODE_LANGUAGE_FRIENDLY_NAME_MAP } from '@lexical/code';
14
14
  import { TRANSFORMERS, registerMarkdownShortcuts } from '@lexical/markdown';
15
- import { createEmptyHistoryState, registerHistory } from '@lexical/history';
16
15
  import { INSERT_TABLE_COMMAND, $getTableCellNodeFromLexicalNode, TableCellNode, TableNode, TableRowNode, setScrollableTablesActive, registerTablePlugin, registerTableSelectionObserver, TableCellHeaderStates, $insertTableRowAtSelection, $insertTableColumnAtSelection, $deleteTableRowAtSelection, $deleteTableColumnAtSelection, $findTableNode, $getTableRowIndexFromTableCellNode, $getTableColumnIndexFromTableCellNode, $findCellNode, $getElementForTableNode } from '@lexical/table';
16
+ import { HistoryExtension } from '@lexical/history';
17
17
  import { marked } from 'marked';
18
18
  import { $insertDataTransferForRichText } from '@lexical/clipboard';
19
19
  import 'prismjs';
@@ -511,19 +511,10 @@ class LexicalToolbarElement extends HTMLElement {
511
511
  }
512
512
 
513
513
  #monitorHistoryChanges() {
514
- this.#listeners.track(this.editor.registerUpdateListener(() => {
515
- this.#updateUndoRedoButtonStates();
516
- }));
517
- }
518
-
519
- #updateUndoRedoButtonStates() {
520
- this.editor.getEditorState().read(() => {
521
- const historyState = this.editorElement.historyState;
522
- if (historyState) {
523
- this.#setButtonDisabled("undo", historyState.undoStack.length === 0);
524
- this.#setButtonDisabled("redo", historyState.redoStack.length === 0);
525
- }
526
- });
514
+ this.#listeners.track(
515
+ this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#setButtonDisabled("undo", !enabled); }, COMMAND_PRIORITY_LOW),
516
+ this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#setButtonDisabled("redo", !enabled); }, COMMAND_PRIORITY_LOW),
517
+ );
527
518
  }
528
519
 
529
520
  #updateButtonStates() {
@@ -557,8 +548,6 @@ class LexicalToolbarElement extends HTMLElement {
557
548
  this.#setButtonPressed("code", isInCode);
558
549
 
559
550
  this.#setButtonPressed("table", isInTable);
560
-
561
- this.#updateUndoRedoButtonStates();
562
551
  }
563
552
 
564
553
  #setButtonPressed(name, isPressed) {
@@ -769,11 +758,11 @@ class LexicalToolbarElement extends HTMLElement {
769
758
 
770
759
  <div class="lexxy-editor__toolbar-spacer" role="separator"></div>
771
760
 
772
- <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
761
+ <button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
773
762
  ${ToolbarIcons.undo}
774
763
  </button>
775
764
 
776
- <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo">
765
+ <button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" disabled aria-disabled="true">
777
766
  ${ToolbarIcons.redo}
778
767
  </button>
779
768
 
@@ -1332,14 +1321,16 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
1332
1321
  }
1333
1322
  }
1334
1323
 
1335
- const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
1336
-
1337
1324
  function $createNodeSelectionWith(...nodes) {
1338
1325
  const selection = $createNodeSelection();
1339
1326
  nodes.forEach(node => selection.add(node.getKey()));
1340
1327
  return selection
1341
1328
  }
1342
1329
 
1330
+ function $isShadowRoot(node) {
1331
+ return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
1332
+ }
1333
+
1343
1334
  function $makeSafeForRoot(node) {
1344
1335
  if ($isTextNode(node)) {
1345
1336
  return $wrapNodeInElement(node, $createParagraphNode)
@@ -1356,6 +1347,11 @@ function getListType(node) {
1356
1347
  return list?.getListType() ?? null
1357
1348
  }
1358
1349
 
1350
+ function isEditorFocused(editor) {
1351
+ const rootElement = editor.getRootElement();
1352
+ return rootElement !== null && rootElement.contains(document.activeElement)
1353
+ }
1354
+
1359
1355
  function $isAtNodeEdge(point, atStart = null) {
1360
1356
  if (atStart === null) {
1361
1357
  return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false)
@@ -1464,6 +1460,35 @@ function isAttachmentSpacerTextNode(node, previousNode, index, childCount) {
1464
1460
  && previousNode instanceof CustomActionTextAttachmentNode
1465
1461
  }
1466
1462
 
1463
+ // Shared, strictly-contained element used to attach ephemeral nodes when we
1464
+ // need to read computed styles (e.g. canonicalizing style values, resolving
1465
+ // CSS custom properties). The container is created once and attached to
1466
+ // `document.body` once; subsequent child mutations happen *inside* the
1467
+ // contained subtree so they do not invalidate style on the rest of the page.
1468
+ //
1469
+ // Without this, `document.body.appendChild(...)` / `element.remove()` calls
1470
+ // forced the browser to re-evaluate every ancestor-dependent selector (`:has()`,
1471
+ // descendant combinators, universal sibling rules) across the document on each
1472
+ // invocation — a 13,000+ element style recalc per call on a typical Basecamp
1473
+ // page.
1474
+
1475
+ let resolverRoot = null;
1476
+
1477
+ function styleResolverRoot() {
1478
+ if (resolverRoot && resolverRoot.isConnected) return resolverRoot
1479
+
1480
+ resolverRoot = document.createElement("div");
1481
+ resolverRoot.setAttribute("aria-hidden", "true");
1482
+ resolverRoot.setAttribute("data-lexxy-style-resolver", "");
1483
+ // `contain: strict` (size, layout, paint, style) isolates everything.
1484
+ // The root itself paints nothing (visibility hidden), has zero
1485
+ // geometric impact (position fixed, intrinsic size via contain), and
1486
+ // never leaks style invalidation to its ancestors.
1487
+ resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;";
1488
+ document.body.appendChild(resolverRoot);
1489
+ return resolverRoot
1490
+ }
1491
+
1467
1492
  function isSelectionHighlighted(selection) {
1468
1493
  if (!$isRangeSelection(selection)) return false
1469
1494
 
@@ -1544,10 +1569,11 @@ class StyleCanonicalizer {
1544
1569
  }
1545
1570
  }
1546
1571
 
1547
- // Separates DOM writes from layout reads to avoid forced reflows. All resolver
1548
- // elements are built inside a fragment, attached once, then read in a single pass.
1549
- // Reading `getComputedStyle` after a write forces the browser to recompute layout,
1550
- // so interleaving writes and reads inside a loop turns one reflow into N.
1572
+ // Separates DOM writes from layout reads to avoid forced reflows, and attaches
1573
+ // resolver elements to a strictly-contained root (outside the normal document
1574
+ // flow) so neither the attach nor the detach invalidate styles on the rest of
1575
+ // the page. Without containment, appending to `document.body` triggered a
1576
+ // page-wide style recalc on every canonicalization pass.
1551
1577
  function computeStyleValues(property, values) {
1552
1578
  const fragment = document.createDocumentFragment();
1553
1579
 
@@ -1557,7 +1583,7 @@ function computeStyleValues(property, values) {
1557
1583
  return element
1558
1584
  });
1559
1585
 
1560
- document.body.appendChild(fragment);
1586
+ styleResolverRoot().appendChild(fragment);
1561
1587
 
1562
1588
  const computed = elements.map(element =>
1563
1589
  window.getComputedStyle(element).getPropertyValue(property)
@@ -2541,6 +2567,10 @@ function debounceAsync(fn, wait) {
2541
2567
  }
2542
2568
  }
2543
2569
 
2570
+ function delay(ms) {
2571
+ return new Promise((resolve) => setTimeout(resolve, ms))
2572
+ }
2573
+
2544
2574
  function nextFrame() {
2545
2575
  return new Promise(requestAnimationFrame)
2546
2576
  }
@@ -2589,6 +2619,109 @@ function parseBoolean(value) {
2589
2619
  return Boolean(value)
2590
2620
  }
2591
2621
 
2622
+ // Payload: Record<nodeKey, { patch?, replace? }>
2623
+ // - patch: plain object, shallow-merged into the existing node's properties
2624
+ // - replace: a LexicalNode instance that replaces the node
2625
+ const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND");
2626
+
2627
+ class RewritableHistoryExtension extends LexxyExtension {
2628
+ #historyState = null
2629
+
2630
+ get lexicalExtension() {
2631
+ return defineExtension({
2632
+ name: "lexxy/rewritable-history",
2633
+ dependencies: [ HistoryExtension ],
2634
+ register: (editor, _config, state) => {
2635
+ const historyOutput = state.getDependency(HistoryExtension).output;
2636
+ this.#historyState = historyOutput.historyState.value;
2637
+
2638
+ return editor.registerCommand(
2639
+ REWRITE_HISTORY_COMMAND,
2640
+ (rewrites) => this.#rewriteHistory(rewrites),
2641
+ COMMAND_PRIORITY_EDITOR
2642
+ )
2643
+ }
2644
+ })
2645
+ }
2646
+
2647
+ get historyState() {
2648
+ return this.#historyState
2649
+ }
2650
+
2651
+ get #allHistoryEntries() {
2652
+ const entries = Array.from(this.#historyState.undoStack);
2653
+ if (this.#historyState.current) entries.push(this.#historyState.current);
2654
+ return entries.concat(this.#historyState.redoStack)
2655
+ }
2656
+
2657
+ #rewriteHistory(rewrites) {
2658
+ $getEditor().update(() => {
2659
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2660
+ const node = $getNodeByKey(nodeKey);
2661
+ if (!node) continue
2662
+
2663
+ if (patch) Object.assign(node.getWritable(), patch);
2664
+ if (replace) node.replace(replace);
2665
+ }
2666
+ }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
2667
+
2668
+ const nodeKeys = Object.keys(rewrites);
2669
+
2670
+ for (const entry of this.#allHistoryEntries) {
2671
+ if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
2672
+
2673
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2674
+ const node = entry.editorState._nodeMap.get(nodeKey);
2675
+ if (!node) continue
2676
+
2677
+ entry.editorState = safeCloneEditorState(entry.editorState);
2678
+
2679
+ if (patch) {
2680
+ entry.editorState._nodeMap.set(nodeKey, $cloneNodeWithPatch(node, patch));
2681
+ } else if (replace) {
2682
+ entry.editorState._nodeMap.set(nodeKey, $cloneNodeAdoptingKey(replace, node));
2683
+ }
2684
+ }
2685
+ }
2686
+
2687
+ return true
2688
+ }
2689
+
2690
+ #entryHasSomeKeys(entry, nodeKeys) {
2691
+ return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
2692
+ }
2693
+
2694
+ #getBackgroundUpdateTags() {
2695
+ const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
2696
+ if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
2697
+ return tags
2698
+ }
2699
+ }
2700
+
2701
+ function $cloneNodeWithPatch(node, patch) {
2702
+ const clone = $cloneWithProperties(node);
2703
+ Object.assign(clone, patch);
2704
+ return clone
2705
+ }
2706
+
2707
+ function $cloneNodeAdoptingKey(source, keyNode) {
2708
+ const clone = $cloneWithProperties(source);
2709
+ clone.__key = keyNode.__key;
2710
+ clone.__parent = keyNode.__parent;
2711
+ clone.__prev = keyNode.__prev;
2712
+ clone.__next = keyNode.__next;
2713
+ return clone
2714
+ }
2715
+
2716
+ // EditorState#clone() keeps the same map reference.
2717
+ // A new Map is needed to prevent editing Lexical's internal map
2718
+ // Warning: this bypasses DEV's safety map freezing
2719
+ function safeCloneEditorState(editorState) {
2720
+ const clone = editorState.clone();
2721
+ clone._nodeMap = new Map(editorState._nodeMap);
2722
+ return clone
2723
+ }
2724
+
2592
2725
  class ActionTextAttachmentNode extends DecoratorNode {
2593
2726
  static getType() {
2594
2727
  return "action_text_attachment"
@@ -2801,6 +2934,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2801
2934
  return figure
2802
2935
  }
2803
2936
 
2937
+ patchAndRewriteHistory(patch) {
2938
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2939
+ [this.getKey()]: { patch }
2940
+ });
2941
+ }
2942
+
2943
+ replaceAndRewriteHistory(node) {
2944
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2945
+ [this.getKey()]: { replace: node }
2946
+ });
2947
+ }
2948
+
2804
2949
  #createDOMForImage(options = {}) {
2805
2950
  const initialSrc = this.previewSrc || this.src;
2806
2951
  const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
@@ -2829,33 +2974,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2829
2974
 
2830
2975
  #handleImageLoaded(img, previewSrc) {
2831
2976
  img.src = this.src;
2832
- this.editor.update(() => {
2833
- if (this.isAttached()) this.getWritable().previewSrc = null;
2834
- }, { tag: this.#backgroundUpdateTags });
2977
+ this.patchAndRewriteHistory({ previewSrc: null });
2835
2978
  this.#revokePreviewSrc(previewSrc);
2836
2979
  }
2837
2980
 
2838
2981
  #handleImageLoadError(previewSrc) {
2839
- this.editor.update(() => {
2840
- if (this.isAttached()) {
2841
- this.getWritable().previewSrc = null;
2842
- this.getWritable().uploadError = true;
2843
- }
2844
- }, { tag: this.#backgroundUpdateTags });
2982
+ this.patchAndRewriteHistory({
2983
+ previewSrc: null,
2984
+ uploadError: true
2985
+ });
2845
2986
  this.#revokePreviewSrc(previewSrc);
2846
2987
  }
2847
2988
 
2848
- get #backgroundUpdateTags() {
2849
- const rootElement = this.editor.getRootElement();
2850
- const editorHasFocus = rootElement !== null && rootElement.contains(document.activeElement);
2851
-
2852
- if (editorHasFocus) {
2853
- return SILENT_UPDATE_TAGS
2854
- } else {
2855
- return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
2856
- }
2857
- }
2858
-
2859
2989
  #revokePreviewSrc(previewSrc) {
2860
2990
  if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
2861
2991
  }
@@ -2917,9 +3047,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2917
3047
  figure.appendChild(this.#createEditableCaption());
2918
3048
  });
2919
3049
 
2920
- this.editor.update(() => {
2921
- if (this.isAttached()) this.getWritable().pendingPreview = false;
2922
- }, { tag: this.#backgroundUpdateTags });
3050
+ this.patchAndRewriteHistory({ pendingPreview: false });
2923
3051
  }
2924
3052
 
2925
3053
  #swapFigureContent(figure, fromClass, toClass, renderContent) {
@@ -3034,12 +3162,6 @@ class Selection {
3034
3162
  this.#clearStaleInlineCodeFormat();
3035
3163
  }
3036
3164
 
3037
- set current(selection) {
3038
- this.editor.update(() => {
3039
- this.#syncSelectedClasses();
3040
- });
3041
- }
3042
-
3043
3165
  get hasNodeSelection() {
3044
3166
  return this.editor.getEditorState().read(() => {
3045
3167
  const selection = $getSelection();
@@ -3368,7 +3490,7 @@ class Selection {
3368
3490
  this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),
3369
3491
 
3370
3492
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3371
- this.current = $getSelection();
3493
+ this.#syncSelectedClasses();
3372
3494
  }, COMMAND_PRIORITY_LOW)
3373
3495
  );
3374
3496
  }
@@ -3566,6 +3688,7 @@ class Selection {
3566
3688
 
3567
3689
  #selectInLexical(node) {
3568
3690
  if ($isDecoratorNode(node)) {
3691
+ $addUpdateTag(HISTORY_MERGE_TAG);
3569
3692
  const selection = $createNodeSelectionWith(node);
3570
3693
  $setSelection(selection);
3571
3694
  return selection
@@ -3931,107 +4054,6 @@ class EditorConfiguration {
3931
4054
  }
3932
4055
  }
3933
4056
 
3934
- class ProvisionalParagraphNode extends ParagraphNode {
3935
- $config() {
3936
- return this.config("provisonal_paragraph", {
3937
- extends: ParagraphNode,
3938
- importDOM: () => null,
3939
- $transform: (node) => {
3940
- node.concretizeIfEdited(node);
3941
- node.removeUnlessRequired(node);
3942
- }
3943
- })
3944
- }
3945
-
3946
- static neededBetween(nodeBefore, nodeAfter) {
3947
- return !$isSelectableElement(nodeBefore, "next")
3948
- && !$isSelectableElement(nodeAfter, "previous")
3949
- }
3950
-
3951
- createDOM(editor) {
3952
- const p = super.createDOM(editor);
3953
- const selected = this.isSelected($getSelection());
3954
- p.classList.add("provisional-paragraph");
3955
- p.classList.toggle("hidden", !selected);
3956
- return p
3957
- }
3958
-
3959
- updateDOM(_prevNode, dom) {
3960
- const selected = this.isSelected($getSelection());
3961
- dom.classList.toggle("hidden", !selected);
3962
- return false
3963
- }
3964
-
3965
- getTextContent() {
3966
- return ""
3967
- }
3968
-
3969
- exportDOM() {
3970
- return {
3971
- element: null
3972
- }
3973
- }
3974
-
3975
- // override as Lexical has an interesting view of collapsed selection in ElementNodes
3976
- // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
3977
- isSelected(selection = null) {
3978
- const targetSelection = selection || $getSelection();
3979
- if (!targetSelection) return false
3980
-
3981
- if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
3982
-
3983
- // A collapsed range selection on the parent element at an offset adjacent to
3984
- // this node means the caret is visually at this paragraph's position. Treat it
3985
- // as selected so the paragraph is visible and the caret renders correctly.
3986
- //
3987
- // Both the offset matching our index (cursor just before us) and index + 1
3988
- // (cursor just after us) count, because the provisional paragraph is an
3989
- // invisible spacer: the browser resolves both offsets to the same visual spot.
3990
- if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
3991
- const { anchor } = targetSelection;
3992
- const parent = this.getParent();
3993
- if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
3994
- const index = this.getIndexWithinParent();
3995
- return anchor.offset === index || anchor.offset === index + 1
3996
- }
3997
- }
3998
-
3999
- return false
4000
- }
4001
-
4002
- removeUnlessRequired(self = this.getLatest()) {
4003
- if (!self.required) self.remove();
4004
- }
4005
-
4006
- concretizeIfEdited(self = this.getLatest()) {
4007
- if (self.getTextContentSize() > 0) {
4008
- self.replace($createParagraphNode(), true);
4009
- }
4010
- }
4011
-
4012
-
4013
- get required() {
4014
- return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
4015
- }
4016
-
4017
- get isDirectRootChild() {
4018
- const parent = this.getParent();
4019
- return $isRootOrShadowRoot(parent)
4020
- }
4021
-
4022
- get immediateSiblings() {
4023
- return [ this.getPreviousSibling(), this.getNextSibling() ]
4024
- }
4025
- }
4026
-
4027
- function $isProvisionalParagraphNode(node) {
4028
- return node instanceof ProvisionalParagraphNode
4029
- }
4030
-
4031
- function $isSelectableElement(node, direction) {
4032
- return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
4033
- }
4034
-
4035
4057
  async function loadFileIntoImage(file, image) {
4036
4058
  return new Promise((resolve) => {
4037
4059
  const reader = new FileReader();
@@ -4067,10 +4089,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4067
4089
  }
4068
4090
 
4069
4091
  constructor(node, key) {
4070
- const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
4071
- super({ ...node, contentType: file.type }, key);
4072
- this.file = file;
4073
- this.fileName = file.name;
4092
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
4093
+ super({ ...node, contentType: file?.type ?? contentType }, key);
4094
+ this.file = file ?? null;
4095
+ this.fileName = file?.name ?? fileName;
4074
4096
  this.uploadUrl = uploadUrl;
4075
4097
  this.blobUrlTemplate = blobUrlTemplate;
4076
4098
  this.progress = progress ?? null;
@@ -4127,6 +4149,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4127
4149
  ...super.exportJSON(),
4128
4150
  type: "action_text_attachment_upload",
4129
4151
  version: 1,
4152
+ fileName: this.fileName,
4153
+ contentType: this.contentType,
4130
4154
  uploadUrl: this.uploadUrl,
4131
4155
  blobUrlTemplate: this.blobUrlTemplate,
4132
4156
  progress: this.progress,
@@ -4151,14 +4175,14 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4151
4175
  }
4152
4176
 
4153
4177
  #getFileExtension() {
4154
- return this.file.name.split(".").pop().toLowerCase()
4178
+ return (this.fileName || "").split(".").pop().toLowerCase()
4155
4179
  }
4156
4180
 
4157
4181
  #createCaption() {
4158
4182
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
4159
4183
 
4160
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" });
4161
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
4184
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
4185
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
4162
4186
  figcaption.appendChild(nameSpan);
4163
4187
  figcaption.appendChild(sizeSpan);
4164
4188
 
@@ -4172,11 +4196,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4172
4196
  #setDimensionsFromImage({ width, height }) {
4173
4197
  if (this.#hasDimensions) return
4174
4198
 
4175
- this.editor.update(() => {
4176
- const writable = this.getWritable();
4177
- writable.width = width;
4178
- writable.height = height;
4179
- }, { tag: this.#backgroundUpdateTags });
4199
+ this.patchAndRewriteHistory({ width, height });
4180
4200
  }
4181
4201
 
4182
4202
  get #hasDimensions() {
@@ -4203,8 +4223,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4203
4223
  } else {
4204
4224
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
4205
4225
  this.editor.update(() => {
4206
- this.showUploadedAttachment(blob);
4207
- }, { tag: this.#backgroundUpdateTags });
4226
+ this.$showUploadedAttachment(blob);
4227
+ });
4208
4228
  }
4209
4229
  });
4210
4230
  }
@@ -4219,7 +4239,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4219
4239
  directUploadWillStoreFileWithXHR: (request) => {
4220
4240
  if (shouldAuthenticateUploads) request.withCredentials = true;
4221
4241
 
4222
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
4242
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
4223
4243
  request.upload.addEventListener("progress", uploadProgressHandler);
4224
4244
  }
4225
4245
  }
@@ -4229,70 +4249,35 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4229
4249
  this.#setProgress(1);
4230
4250
  }
4231
4251
 
4232
- #handleUploadProgress(event) {
4252
+ #handleUploadProgress(event, request) {
4233
4253
  const progress = Math.round(event.loaded / event.total * 100);
4234
- this.#setProgress(progress);
4235
- this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4254
+ try {
4255
+ this.#setProgress(progress);
4256
+ this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4257
+ } catch {
4258
+ request.abort();
4259
+ }
4236
4260
  }
4237
4261
 
4238
4262
  #setProgress(progress) {
4239
- this.editor.update(() => {
4240
- this.getWritable().progress = progress;
4241
- }, { tag: this.#backgroundUpdateTags });
4263
+ this.patchAndRewriteHistory({ progress });
4242
4264
  }
4243
4265
 
4244
4266
  #handleUploadError(error) {
4245
4267
  console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4246
- this.editor.update(() => {
4247
- this.getWritable().uploadError = true;
4248
- }, { tag: this.#backgroundUpdateTags });
4268
+
4269
+ this.patchAndRewriteHistory({ uploadError: true });
4249
4270
  }
4250
4271
 
4251
- showUploadedAttachment(blob) {
4272
+ $showUploadedAttachment(blob) {
4252
4273
  const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4253
4274
 
4254
4275
  const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4255
- const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode;
4256
- this.replace(replacementNode);
4257
-
4258
- if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) {
4259
- replacementNode.selectNext();
4260
- }
4276
+ this.replaceAndRewriteHistory(replacementNode);
4261
4277
 
4262
4278
  return replacementNode.getKey()
4263
4279
  }
4264
4280
 
4265
- // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
4266
- // fire while the user is focused on another element (e.g., a title field). Without
4267
- // SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
4268
- // the editor, stealing focus from wherever the user is currently typing.
4269
- get #backgroundUpdateTags() {
4270
- if (this.#editorHasFocus) {
4271
- return SILENT_UPDATE_TAGS
4272
- } else {
4273
- return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
4274
- }
4275
- }
4276
-
4277
- get #editorHasFocus() {
4278
- const rootElement = this.editor.getRootElement();
4279
- return rootElement !== null && rootElement.contains(document.activeElement)
4280
- }
4281
-
4282
- get #selectionIncludesUploadNode() {
4283
- const selection = $getSelection();
4284
- if (selection === null) return false
4285
-
4286
- if (selection.getNodes().some((node) => node.is(this))) return true
4287
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
4288
-
4289
- const anchorNode = selection.anchor.getNode();
4290
- if (!$isProvisionalParagraphNode(anchorNode) || !anchorNode.isEmpty()) return false
4291
-
4292
- const previousSibling = anchorNode.getPreviousSibling();
4293
- return previousSibling !== null && previousSibling.is(this)
4294
- }
4295
-
4296
4281
  #toActionTextAttachmentNodeWith(blob, previewSrc) {
4297
4282
  const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
4298
4283
  return conversion.toAttachmentNode()
@@ -4642,35 +4627,138 @@ class GalleryUploader extends Uploader {
4642
4627
  }
4643
4628
  }
4644
4629
 
4645
- class Contents {
4646
- constructor(editorElement) {
4647
- this.editorElement = editorElement;
4648
- this.editor = editorElement.editor;
4630
+ class NodeInserter {
4631
+ static for(selection) {
4632
+ const INSERTERS = [
4633
+ CodeNodeInserter,
4634
+ QuoteNodeInserter,
4635
+ ShadowRootNodeInserter,
4636
+ NodeSelectionNodeInserter
4637
+ ];
4638
+ const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
4639
+ return Inserter ? new Inserter(selection) : selection
4649
4640
  }
4650
4641
 
4651
- dispose() {
4652
- this.editorElement = null;
4653
- this.editor = null;
4642
+ constructor(selection) {
4643
+ this.selection = selection;
4654
4644
  }
4645
+ }
4655
4646
 
4656
- get selection() { return this.editorElement.selection }
4657
-
4658
- insertHtml(html, { tag } = {}) {
4659
- this.insertDOM(parseHtml(html), { tag });
4647
+ class CodeNodeInserter extends NodeInserter {
4648
+ static handles(selection) {
4649
+ return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
4660
4650
  }
4661
4651
 
4662
- insertDOM(doc, { tag } = {}) {
4663
- this.#unwrapPlaceholderAnchors(doc);
4652
+ insertNodes(nodes) {
4653
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4664
4654
 
4665
- this.editor.update(() => {
4666
- if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4655
+ $ensureForwardRangeSelection(this.selection);
4656
+ const focusNode = this.selection.focus.getNode();
4657
+ const codeNode = $getNearestNodeOfType(focusNode, CodeNode);
4658
+ const insertionIndex = focusNode.is(codeNode) ? 0 : focusNode.getIndexWithinParent();
4667
4659
 
4668
- const nodes = $generateNodesFromDOM(this.editor, doc);
4669
- if (!this.#insertUploadNodes(nodes)) {
4670
- this.insertAtCursor(...nodes);
4671
- }
4672
- }, { tag });
4673
- }
4660
+ const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
4661
+
4662
+ for (const node of nodes) {
4663
+ if (!node.isAttached()) continue
4664
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
4665
+
4666
+ caret.insert(this.#convertNodeToCodeChild(node));
4667
+ }
4668
+
4669
+ caret.getNodeAtCaret().selectEnd();
4670
+ }
4671
+
4672
+ #convertNodeToCodeChild(node) {
4673
+ if ($isLineBreakNode(node)) {
4674
+ return node
4675
+ } else {
4676
+ node.remove();
4677
+ return $createTextNode(node.getTextContent())
4678
+ }
4679
+ }
4680
+
4681
+ }
4682
+
4683
+ // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
4684
+ class QuoteNodeInserter extends NodeInserter {
4685
+ static handles(selection) {
4686
+ return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
4687
+ }
4688
+
4689
+ insertNodes(nodes) {
4690
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4691
+
4692
+ $ensureForwardRangeSelection(this.selection);
4693
+ let lastNode = this.selection.focus.getNode();
4694
+ for (const node of nodes) {
4695
+ lastNode = lastNode.insertAfter(node);
4696
+ }
4697
+
4698
+ lastNode.selectEnd();
4699
+ }
4700
+ }
4701
+
4702
+ class ShadowRootNodeInserter extends NodeInserter {
4703
+ static handles(selection) {
4704
+ return $isShadowRoot(selection?.anchor.getNode())
4705
+ }
4706
+
4707
+ insertNodes(nodes) {
4708
+ const anchorNode = this.selection.anchor.getNode();
4709
+ const paragraph = $createParagraphNode();
4710
+ anchorNode.append(paragraph);
4711
+
4712
+ paragraph.selectStart().insertNodes(nodes);
4713
+ }
4714
+ }
4715
+
4716
+ class NodeSelectionNodeInserter extends NodeInserter {
4717
+ static handles(selection) {
4718
+ return $isNodeSelection(selection)
4719
+ }
4720
+
4721
+ insertNodes(nodes) {
4722
+ const selectedNodes = this.selection.getNodes();
4723
+
4724
+ // Overrides Lexical's default behavior of _removing_ the currently selected nodes
4725
+ // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
4726
+ let lastNode = selectedNodes.at(-1);
4727
+ for (const node of nodes) {
4728
+ lastNode = lastNode.insertAfter(node);
4729
+ }
4730
+ }
4731
+ }
4732
+
4733
+ class Contents {
4734
+ constructor(editorElement) {
4735
+ this.editorElement = editorElement;
4736
+ this.editor = editorElement.editor;
4737
+ }
4738
+
4739
+ dispose() {
4740
+ this.editorElement = null;
4741
+ this.editor = null;
4742
+ }
4743
+
4744
+ get selection() { return this.editorElement.selection }
4745
+
4746
+ insertHtml(html, { tag } = {}) {
4747
+ this.insertDOM(parseHtml(html), { tag });
4748
+ }
4749
+
4750
+ insertDOM(doc, { tag } = {}) {
4751
+ this.#unwrapPlaceholderAnchors(doc);
4752
+
4753
+ this.editor.update(() => {
4754
+ if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4755
+
4756
+ const nodes = $generateNodesFromDOM(this.editor, doc);
4757
+ if (!this.#insertUploadNodes(nodes)) {
4758
+ this.insertAtCursor(...nodes);
4759
+ }
4760
+ }, { tag });
4761
+ }
4674
4762
 
4675
4763
  insertAtCursor(...nodes) {
4676
4764
  const selection = $getSelection() ?? $getRoot().selectEnd();
@@ -4912,7 +5000,7 @@ class Contents {
4912
5000
  const node = $getNodeByKey(nodeKey);
4913
5001
  if (!(node instanceof ActionTextAttachmentUploadNode)) return
4914
5002
 
4915
- const replacementNodeKey = node.showUploadedAttachment(blob);
5003
+ const replacementNodeKey = node.$showUploadedAttachment(blob);
4916
5004
  if (replacementNodeKey) {
4917
5005
  nodeKey = replacementNodeKey;
4918
5006
  }
@@ -5251,113 +5339,6 @@ class Contents {
5251
5339
  }
5252
5340
  }
5253
5341
 
5254
- function $isShadowRoot(node) {
5255
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
5256
- }
5257
-
5258
- class NodeInserter {
5259
- static for(selection) {
5260
- const INSERTERS = [
5261
- CodeNodeInserter,
5262
- QuoteNodeInserter,
5263
- ShadowRootNodeInserter,
5264
- NodeSelectionNodeInserter
5265
- ];
5266
- const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5267
- return Inserter ? new Inserter(selection) : selection
5268
- }
5269
-
5270
- constructor(selection) {
5271
- this.selection = selection;
5272
- }
5273
- }
5274
-
5275
- class CodeNodeInserter extends NodeInserter {
5276
- static handles(selection) {
5277
- return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5278
- }
5279
-
5280
- insertNodes(nodes) {
5281
- if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5282
-
5283
- $ensureForwardRangeSelection(this.selection);
5284
- const focusNode = this.selection.focus.getNode();
5285
- const codeNode = $getNearestNodeOfType(focusNode, CodeNode);
5286
- const insertionIndex = focusNode.is(codeNode) ? 0 : focusNode.getIndexWithinParent();
5287
-
5288
- const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5289
-
5290
- for (const node of nodes) {
5291
- if (!node.isAttached()) continue
5292
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5293
-
5294
- caret.insert(this.#convertNodeToCodeChild(node));
5295
- }
5296
-
5297
- caret.getNodeAtCaret().selectEnd();
5298
- }
5299
-
5300
- #convertNodeToCodeChild(node) {
5301
- if ($isLineBreakNode(node)) {
5302
- return node
5303
- } else {
5304
- node.remove();
5305
- return $createTextNode(node.getTextContent())
5306
- }
5307
- }
5308
-
5309
- }
5310
-
5311
- // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
5312
- class QuoteNodeInserter extends NodeInserter {
5313
- static handles(selection) {
5314
- return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
5315
- }
5316
-
5317
- insertNodes(nodes) {
5318
- if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5319
-
5320
- $ensureForwardRangeSelection(this.selection);
5321
- let lastNode = this.selection.focus.getNode();
5322
- for (const node of nodes) {
5323
- lastNode = lastNode.insertAfter(node);
5324
- }
5325
-
5326
- lastNode.selectEnd();
5327
- }
5328
- }
5329
-
5330
- class ShadowRootNodeInserter extends NodeInserter {
5331
- static handles(selection) {
5332
- return $isShadowRoot(selection?.anchor.getNode())
5333
- }
5334
-
5335
- insertNodes(nodes) {
5336
- const anchorNode = this.selection.anchor.getNode();
5337
- const paragraph = $createParagraphNode();
5338
- anchorNode.append(paragraph);
5339
-
5340
- paragraph.selectStart().insertNodes(nodes);
5341
- }
5342
- }
5343
-
5344
- class NodeSelectionNodeInserter extends NodeInserter {
5345
- static handles(selection) {
5346
- return $isNodeSelection(selection)
5347
- }
5348
-
5349
- insertNodes(nodes) {
5350
- const selectedNodes = this.selection.getNodes();
5351
-
5352
- // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5353
- // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5354
- let lastNode = selectedNodes.at(-1);
5355
- for (const node of nodes) {
5356
- lastNode = lastNode.insertAfter(node);
5357
- }
5358
- }
5359
- }
5360
-
5361
5342
  class Clipboard {
5362
5343
  constructor(editorElement) {
5363
5344
  this.editorElement = editorElement;
@@ -5664,6 +5645,107 @@ function unwrapSpans(element) {
5664
5645
  return element
5665
5646
  }
5666
5647
 
5648
+ class ProvisionalParagraphNode extends ParagraphNode {
5649
+ $config() {
5650
+ return this.config("provisonal_paragraph", {
5651
+ extends: ParagraphNode,
5652
+ importDOM: () => null,
5653
+ $transform: (node) => {
5654
+ node.concretizeIfEdited(node);
5655
+ node.removeUnlessRequired(node);
5656
+ }
5657
+ })
5658
+ }
5659
+
5660
+ static neededBetween(nodeBefore, nodeAfter) {
5661
+ return !$isSelectableElement(nodeBefore, "next")
5662
+ && !$isSelectableElement(nodeAfter, "previous")
5663
+ }
5664
+
5665
+ createDOM(editor) {
5666
+ const p = super.createDOM(editor);
5667
+ const selected = this.isSelected($getSelection());
5668
+ p.classList.add("provisional-paragraph");
5669
+ p.classList.toggle("hidden", !selected);
5670
+ return p
5671
+ }
5672
+
5673
+ updateDOM(_prevNode, dom) {
5674
+ const selected = this.isSelected($getSelection());
5675
+ dom.classList.toggle("hidden", !selected);
5676
+ return false
5677
+ }
5678
+
5679
+ getTextContent() {
5680
+ return ""
5681
+ }
5682
+
5683
+ exportDOM() {
5684
+ return {
5685
+ element: null
5686
+ }
5687
+ }
5688
+
5689
+ // override as Lexical has an interesting view of collapsed selection in ElementNodes
5690
+ // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5691
+ isSelected(selection = null) {
5692
+ const targetSelection = selection || $getSelection();
5693
+ if (!targetSelection) return false
5694
+
5695
+ if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
5696
+
5697
+ // A collapsed range selection on the parent element at an offset adjacent to
5698
+ // this node means the caret is visually at this paragraph's position. Treat it
5699
+ // as selected so the paragraph is visible and the caret renders correctly.
5700
+ //
5701
+ // Both the offset matching our index (cursor just before us) and index + 1
5702
+ // (cursor just after us) count, because the provisional paragraph is an
5703
+ // invisible spacer: the browser resolves both offsets to the same visual spot.
5704
+ if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
5705
+ const { anchor } = targetSelection;
5706
+ const parent = this.getParent();
5707
+ if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
5708
+ const index = this.getIndexWithinParent();
5709
+ return anchor.offset === index || anchor.offset === index + 1
5710
+ }
5711
+ }
5712
+
5713
+ return false
5714
+ }
5715
+
5716
+ removeUnlessRequired(self = this.getLatest()) {
5717
+ if (!self.required) self.remove();
5718
+ }
5719
+
5720
+ concretizeIfEdited(self = this.getLatest()) {
5721
+ if (self.getTextContentSize() > 0) {
5722
+ self.replace($createParagraphNode(), true);
5723
+ }
5724
+ }
5725
+
5726
+
5727
+ get required() {
5728
+ return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
5729
+ }
5730
+
5731
+ get isDirectRootChild() {
5732
+ const parent = this.getParent();
5733
+ return $isRootOrShadowRoot(parent)
5734
+ }
5735
+
5736
+ get immediateSiblings() {
5737
+ return [ this.getPreviousSibling(), this.getNextSibling() ]
5738
+ }
5739
+ }
5740
+
5741
+ function $isProvisionalParagraphNode(node) {
5742
+ return node instanceof ProvisionalParagraphNode
5743
+ }
5744
+
5745
+ function $isSelectableElement(node, direction) {
5746
+ return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
5747
+ }
5748
+
5667
5749
  class ProvisionalParagraphExtension extends LexxyExtension {
5668
5750
  get lexicalExtension() {
5669
5751
  return defineExtension({
@@ -5685,6 +5767,8 @@ class ProvisionalParagraphExtension extends LexxyExtension {
5685
5767
  }
5686
5768
 
5687
5769
  function $insertRequiredProvisionalParagraphs(rootNode) {
5770
+ const nodeBeforeRootSelection = $nodeBeforeRootSelection(rootNode);
5771
+
5688
5772
  const firstNode = rootNode.getFirstChild();
5689
5773
  if (ProvisionalParagraphNode.neededBetween(null, firstNode)) {
5690
5774
  $insertFirst(rootNode, new ProvisionalParagraphNode);
@@ -5694,10 +5778,18 @@ function $insertRequiredProvisionalParagraphs(rootNode) {
5694
5778
  const nextNode = node.getNextSibling();
5695
5779
  if (ProvisionalParagraphNode.neededBetween(node, nextNode)) {
5696
5780
  node.insertAfter(new ProvisionalParagraphNode);
5781
+ if (node.is(nodeBeforeRootSelection)) node.selectNext();
5697
5782
  }
5698
5783
  }
5699
5784
  }
5700
5785
 
5786
+ function $nodeBeforeRootSelection(rootNode) {
5787
+ const selection = $getSelection();
5788
+ if (!$isRootOrShadowRoot(selection?.anchor?.getNode())) return null
5789
+
5790
+ return rootNode.getChildAtIndex(selection.anchor.offset - 1)
5791
+ }
5792
+
5701
5793
  function $removeUnneededProvisionalParagraphs(rootNode) {
5702
5794
  for (const provisionalParagraph of $getAllProvisionalParagraphs(rootNode)) {
5703
5795
  provisionalParagraph.removeUnlessRequired();
@@ -5705,6 +5797,9 @@ function $removeUnneededProvisionalParagraphs(rootNode) {
5705
5797
  }
5706
5798
 
5707
5799
  function $markAllProvisionalParagraphsDirty() {
5800
+ // Selection-driven visibility updates must not become standalone undo steps.
5801
+ $addUpdateTag(HISTORY_MERGE_TAG);
5802
+
5708
5803
  for (const provisionalParagraph of $getAllProvisionalParagraphs()) {
5709
5804
  provisionalParagraph.markDirty();
5710
5805
  }
@@ -6585,54 +6680,58 @@ class LinkOpenerExtension extends LexxyExtension {
6585
6680
  get lexicalExtension() {
6586
6681
  return defineExtension({
6587
6682
  name: "lexxy/link-opener",
6588
- register: () => {
6589
- return mergeRegister(
6590
- registerEventListener(window, "keydown", this.#update.bind(this)),
6591
- registerEventListener(window, "keyup", this.#update.bind(this)),
6592
- registerEventListener(window, "blur", this.#disable.bind(this)),
6593
- registerEventListener(window, "focus", this.#refresh.bind(this))
6594
- )
6595
- }
6683
+ register: (editor) => mergeRegister$1(
6684
+ editor.registerCommand(CLICK_COMMAND, this.#handleClick.bind(this), COMMAND_PRIORITY_NORMAL),
6685
+ registerEventListener(this.editorElement.editorContentElement, "auxclick", this.#handleAuxClick.bind(this)),
6686
+ registerEventListener(window, "keydown", this.#handleKey.bind(this)),
6687
+ registerEventListener(window, "keyup", this.#handleKey.bind(this)),
6688
+ registerEventListener(window, "focus", this.#handleFocus.bind(this))
6689
+ )
6596
6690
  })
6597
6691
  }
6598
6692
 
6599
- #update(event) {
6693
+ #handleClick(event) {
6600
6694
  if (this.#isModified(event)) {
6601
- this.#enable();
6695
+ return $openLink(event.target)
6602
6696
  } else {
6603
- this.#disable();
6697
+ return false
6604
6698
  }
6605
6699
  }
6606
6700
 
6607
- #refresh() {
6608
- // Chrome dispatches events without modifier keys *for a while* after changing tabs
6609
- setTimeout(() => {
6610
- window.addEventListener("mousemove", this.#update.bind(this), { once: true });
6611
- }, 200);
6701
+ #handleAuxClick(event) {
6702
+ if (event.button === 1) {
6703
+ this.editorElement.editor.read(() => $openLink(event.target));
6704
+ }
6612
6705
  }
6613
6706
 
6614
- #isModified(event) {
6615
- return IS_APPLE ? event.metaKey : event.ctrlKey
6707
+ #handleKey(event) {
6708
+ this.#updateOpenableAttribute(event);
6616
6709
  }
6617
6710
 
6618
- #enable() {
6619
- for (const anchor of this.#anchors) {
6620
- anchor.setAttribute("contenteditable", "false");
6621
- anchor.setAttribute("target", "_blank");
6622
- anchor.setAttribute("rel", "noopener noreferrer");
6623
- }
6711
+ // Chrome dispatches events without modifier keys *for a while* after changing tabs
6712
+ async #handleFocus() {
6713
+ await delay(200);
6714
+ this.editorElement.addEventListener("mousemove", this.#updateOpenableAttribute.bind(this), { once: true });
6624
6715
  }
6625
6716
 
6626
- #disable() {
6627
- for (const anchor of this.#anchors) {
6628
- anchor.removeAttribute("contenteditable");
6629
- anchor.removeAttribute("target");
6630
- anchor.removeAttribute("rel");
6631
- }
6717
+ #updateOpenableAttribute(event) {
6718
+ this.editorElement.toggleAttribute("data-links-openable", this.#isModified(event));
6632
6719
  }
6633
6720
 
6634
- get #anchors() {
6635
- return this.editorElement.editorContentElement?.querySelectorAll("a") ?? []
6721
+ #isModified(event) {
6722
+ return IS_APPLE ? event.metaKey : event.ctrlKey
6723
+ }
6724
+ }
6725
+
6726
+ function $openLink(target) {
6727
+ const node = $getNearestNodeFromDOMNode(target);
6728
+ const linkNode = $findMatchingParent(node, $isLinkNode);
6729
+ if (linkNode) {
6730
+ const url = linkNode.sanitizeUrl(linkNode.getURL());
6731
+ window.open(url, "_blank", "noopener,noreferrer");
6732
+ return true
6733
+ } else {
6734
+ return false
6636
6735
  }
6637
6736
  }
6638
6737
 
@@ -6648,6 +6747,7 @@ class LexicalEditorElement extends HTMLElement {
6648
6747
  #editorInitializedRafId = null
6649
6748
  #listeners = new ListenerBin()
6650
6749
  #disposables = []
6750
+ #historyState = { undo: false, redo: false }
6651
6751
 
6652
6752
  constructor() {
6653
6753
  super();
@@ -6739,6 +6839,7 @@ class LexicalEditorElement extends HTMLElement {
6739
6839
  HighlightExtension,
6740
6840
  TrixContentExtension,
6741
6841
  TablesExtension,
6842
+ RewritableHistoryExtension,
6742
6843
  AttachmentsExtension,
6743
6844
  FormatEscapeExtension,
6744
6845
  LinkOpenerExtension
@@ -6821,9 +6922,19 @@ class LexicalEditorElement extends HTMLElement {
6821
6922
  }
6822
6923
 
6823
6924
  focus() {
6925
+ // `editor.focus()` commits a reconciler update to position the cursor.
6926
+ // Skip if the contenteditable already owns focus — the update would be a
6927
+ // no-op but still triggers a full style/layout pass on pages with large
6928
+ // DOMs.
6929
+ if (this.#isContentFocused) return
6930
+
6824
6931
  this.editor.focus(() => this.#onFocus());
6825
6932
  }
6826
6933
 
6934
+ get #isContentFocused() {
6935
+ return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
6936
+ }
6937
+
6827
6938
  get value() {
6828
6939
  if (!this.cachedValue) {
6829
6940
  this.editor?.getEditorState().read(() => {
@@ -6836,19 +6947,21 @@ class LexicalEditorElement extends HTMLElement {
6836
6947
 
6837
6948
  set value(html) {
6838
6949
  this.editor.update(() => {
6839
- $addUpdateTag(SKIP_DOM_SELECTION_TAG);
6840
- const root = $getRoot();
6841
- root.clear();
6842
- root.append(...this.#parseHtmlIntoLexicalNodes(html));
6843
- root.selectEnd();
6950
+ $getRoot()
6951
+ .clear()
6952
+ .selectEnd()
6953
+ .insertNodes(this.#parseHtmlIntoLexicalNodes(html));
6844
6954
 
6845
6955
  this.#toggleEmptyStatus();
6956
+ }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG });
6957
+ }
6846
6958
 
6847
- // The first time you set the value, when the editor is empty, it seems to leave Lexical
6848
- // in an inconsistent state until, at least, you focus. You can type but adding attachments
6849
- // fails because no root node detected. This is a workaround to deal with the issue.
6850
- requestAnimationFrame(() => this.editor?.update(() => { }));
6851
- });
6959
+ get canUndo() {
6960
+ return this.#historyState.undo
6961
+ }
6962
+
6963
+ get canRedo() {
6964
+ return this.#historyState.redo
6852
6965
  }
6853
6966
 
6854
6967
  #parseHtmlIntoLexicalNodes(html) {
@@ -6884,6 +6997,7 @@ class LexicalEditorElement extends HTMLElement {
6884
6997
  this.#registerComponents();
6885
6998
  this.#handleEnter();
6886
6999
  this.#registerFocusEvents();
7000
+ this.#registerHistoryEvents();
6887
7001
  this.#attachDebugHooks();
6888
7002
  this.#attachToolbar();
6889
7003
  this.#configureSanitizer();
@@ -6980,8 +7094,10 @@ class LexicalEditorElement extends HTMLElement {
6980
7094
  }
6981
7095
 
6982
7096
  #loadInitialValue() {
6983
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p></p>";
6984
- this.value = this.#initialValue = initialHtml;
7097
+ const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7098
+ this.editor.update(() => {
7099
+ this.value = this.#initialValue = initialHtml;
7100
+ }, { tag: HISTORY_MERGE_TAG });
6985
7101
  }
6986
7102
 
6987
7103
  #resetBeforeTurboCaches() {
@@ -7031,8 +7147,6 @@ class LexicalEditorElement extends HTMLElement {
7031
7147
  } else {
7032
7148
  registered.push(registerPlainText(this.editor));
7033
7149
  }
7034
- this.historyState = createEmptyHistoryState();
7035
- registered.push(registerHistory(this.editor, this.historyState, 20));
7036
7150
 
7037
7151
  this.#listeners.track(...registered);
7038
7152
  }
@@ -7115,6 +7229,12 @@ class LexicalEditorElement extends HTMLElement {
7115
7229
  }
7116
7230
  }
7117
7231
 
7232
+ #registerHistoryEvents() {
7233
+ this.#listeners.track(
7234
+ this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#historyState.undo = enabled; }, COMMAND_PRIORITY_NORMAL),
7235
+ this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#historyState.redo = enabled; }, COMMAND_PRIORITY_NORMAL)
7236
+ );
7237
+ }
7118
7238
 
7119
7239
  #attachDebugHooks() {
7120
7240
  return
@@ -7205,8 +7325,8 @@ class LexicalEditorElement extends HTMLElement {
7205
7325
  heading: { active: format.isInHeading, enabled: true },
7206
7326
  "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
7207
7327
  "ordered-list": { active: format.isInList && format.listType === "number", enabled: true },
7208
- undo: { active: false, enabled: this.historyState?.undoStack.length > 0 },
7209
- redo: { active: false, enabled: this.historyState?.redoStack.length > 0 }
7328
+ undo: { active: false, enabled: this.canUndo },
7329
+ redo: { active: false, enabled: this.canRedo }
7210
7330
  };
7211
7331
 
7212
7332
  linkHref = linkNode ? linkNode.getURL() : null;
@@ -7282,7 +7402,7 @@ class LexicalEditorElement extends HTMLElement {
7282
7402
  return { element, name: cssValue }
7283
7403
  });
7284
7404
 
7285
- this.appendChild(container);
7405
+ styleResolverRoot().appendChild(container);
7286
7406
 
7287
7407
  const resolved = resolvers.map(({ element, name }) => ({
7288
7408
  name,
@@ -68,6 +68,10 @@
68
68
  outline-offset: var(--lexxy-focus-ring-offset);
69
69
  }
70
70
 
71
+ &[data-links-openable] a {
72
+ cursor: pointer;
73
+ }
74
+
71
75
  /* Tables */
72
76
  /* ------------------------------------------------------------------------ */
73
77
 
@@ -399,6 +403,13 @@
399
403
  min-block-size: var(--lexxy-editor-rows);
400
404
  outline: 0;
401
405
  padding: var(--lexxy-editor-padding);
406
+
407
+ /* Isolate the contenteditable root's layout and style. Lexical's reconciler
408
+ commits mutations inside this element (nodes appended, text inserted,
409
+ class flipped) on every update; containment keeps those mutations from
410
+ invalidating ancestor-dependent selectors and sibling layout elsewhere
411
+ in the editor. */
412
+ contain: layout style;
402
413
  }
403
414
 
404
415
  :where(.lexxy-editor--drag-over) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.9-beta",
3
+ "version": "0.9.9-beta.preview2",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",
@@ -48,21 +48,21 @@
48
48
  "release": "yarn build:npm && yarn publish"
49
49
  },
50
50
  "dependencies": {
51
- "@lexical/clipboard": "^0.42.0",
52
- "@lexical/code": "^0.42.0",
53
- "@lexical/extension": "^0.42.0",
54
- "@lexical/history": "^0.42.0",
55
- "@lexical/html": "^0.42.0",
56
- "@lexical/link": "^0.42.0",
57
- "@lexical/list": "^0.42.0",
58
- "@lexical/markdown": "^0.42.0",
59
- "@lexical/plain-text": "^0.42.0",
60
- "@lexical/rich-text": "^0.42.0",
61
- "@lexical/selection": "^0.42.0",
62
- "@lexical/table": "^0.42.0",
63
- "@lexical/utils": "^0.42.0",
51
+ "@lexical/clipboard": "^0.43.0",
52
+ "@lexical/code": "^0.43.0",
53
+ "@lexical/extension": "^0.43.0",
54
+ "@lexical/history": "^0.43.0",
55
+ "@lexical/html": "^0.43.0",
56
+ "@lexical/link": "^0.43.0",
57
+ "@lexical/list": "^0.43.0",
58
+ "@lexical/markdown": "^0.43.0",
59
+ "@lexical/plain-text": "^0.43.0",
60
+ "@lexical/rich-text": "^0.43.0",
61
+ "@lexical/selection": "^0.43.0",
62
+ "@lexical/table": "^0.43.0",
63
+ "@lexical/utils": "^0.43.0",
64
64
  "dompurify": "^3.3.0",
65
- "lexical": "^0.42.0",
65
+ "lexical": "^0.43.0",
66
66
  "marked": "^16.4.1",
67
67
  "prismjs": "^1.30.0"
68
68
  },