@37signals/lexxy 0.9.9-beta-preview1 → 0.9.9-beta.preview3.domselection

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)
@@ -2571,6 +2567,10 @@ function debounceAsync(fn, wait) {
2571
2567
  }
2572
2568
  }
2573
2569
 
2570
+ function delay(ms) {
2571
+ return new Promise((resolve) => setTimeout(resolve, ms))
2572
+ }
2573
+
2574
2574
  function nextFrame() {
2575
2575
  return new Promise(requestAnimationFrame)
2576
2576
  }
@@ -2619,6 +2619,124 @@ function parseBoolean(value) {
2619
2619
  return Boolean(value)
2620
2620
  }
2621
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
+ this.#applyRewritesImmediatelyToCurrentState(rewrites);
2659
+ this.#applyRewritesToHistory(rewrites);
2660
+
2661
+ return true
2662
+ }
2663
+
2664
+ #applyRewritesImmediatelyToCurrentState(rewrites) {
2665
+ $getEditor().update(() => {
2666
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2667
+ const node = $getNodeByKey(nodeKey);
2668
+ if (!node) continue
2669
+
2670
+ if (patch) Object.assign(node.getWritable(), patch);
2671
+ if (replace) node.replace(replace);
2672
+ }
2673
+ }, { discrete: true, tag: this.#getBackgroundUpdateTags() });
2674
+ }
2675
+
2676
+ #applyRewritesToHistory(rewrites) {
2677
+ const nodeKeys = Object.keys(rewrites);
2678
+
2679
+ for (const entry of this.#allHistoryEntries) {
2680
+ if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue
2681
+
2682
+ const editorState = entry.editorState = safeCloneEditorState(entry.editorState);
2683
+
2684
+ for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) {
2685
+ const node = editorState._nodeMap.get(nodeKey);
2686
+ if (!node) continue
2687
+
2688
+ if (patch) {
2689
+ this.#patchNodeInEditorState(editorState, node, patch);
2690
+ } else if (replace) {
2691
+ this.#replaceNodeInEditorState(editorState, node, replace);
2692
+ }
2693
+ }
2694
+ }
2695
+ }
2696
+
2697
+ #entryHasSomeKeys(entry, nodeKeys) {
2698
+ return nodeKeys.some(key => entry.editorState._nodeMap.has(key))
2699
+ }
2700
+
2701
+ #getBackgroundUpdateTags() {
2702
+ const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ];
2703
+ if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG); }
2704
+ return tags
2705
+ }
2706
+
2707
+ #patchNodeInEditorState(editorState, node, patch) {
2708
+ editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch));
2709
+ }
2710
+
2711
+ #replaceNodeInEditorState(editorState, node, replaceWith) {
2712
+ editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node));
2713
+ }
2714
+ }
2715
+
2716
+ function $cloneNodeWithPatch(node, patch) {
2717
+ const clone = $cloneWithProperties(node);
2718
+ Object.assign(clone, patch);
2719
+ return clone
2720
+ }
2721
+
2722
+ function $cloneNodeAdoptingKeys(node, previousNode) {
2723
+ const clone = $cloneWithProperties(node);
2724
+ clone.__key = previousNode.__key;
2725
+ clone.__parent = previousNode.__parent;
2726
+ clone.__prev = previousNode.__prev;
2727
+ clone.__next = previousNode.__next;
2728
+ return clone
2729
+ }
2730
+
2731
+ // EditorState#clone() keeps the same map reference.
2732
+ // A new Map is needed to prevent editing Lexical's internal map
2733
+ // Warning: this bypasses DEV's safety map freezing
2734
+ function safeCloneEditorState(editorState) {
2735
+ const clone = editorState.clone();
2736
+ clone._nodeMap = new Map(editorState._nodeMap);
2737
+ return clone
2738
+ }
2739
+
2622
2740
  class ActionTextAttachmentNode extends DecoratorNode {
2623
2741
  static getType() {
2624
2742
  return "action_text_attachment"
@@ -2831,6 +2949,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2831
2949
  return figure
2832
2950
  }
2833
2951
 
2952
+ patchAndRewriteHistory(patch) {
2953
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2954
+ [this.getKey()]: { patch }
2955
+ });
2956
+ }
2957
+
2958
+ replaceAndRewriteHistory(node) {
2959
+ this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, {
2960
+ [this.getKey()]: { replace: node }
2961
+ });
2962
+ }
2963
+
2834
2964
  #createDOMForImage(options = {}) {
2835
2965
  const initialSrc = this.previewSrc || this.src;
2836
2966
  const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
@@ -2859,33 +2989,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2859
2989
 
2860
2990
  #handleImageLoaded(img, previewSrc) {
2861
2991
  img.src = this.src;
2862
- this.editor.update(() => {
2863
- if (this.isAttached()) this.getWritable().previewSrc = null;
2864
- }, { tag: this.#backgroundUpdateTags });
2992
+ this.patchAndRewriteHistory({ previewSrc: null });
2865
2993
  this.#revokePreviewSrc(previewSrc);
2866
2994
  }
2867
2995
 
2868
2996
  #handleImageLoadError(previewSrc) {
2869
- this.editor.update(() => {
2870
- if (this.isAttached()) {
2871
- this.getWritable().previewSrc = null;
2872
- this.getWritable().uploadError = true;
2873
- }
2874
- }, { tag: this.#backgroundUpdateTags });
2997
+ this.patchAndRewriteHistory({
2998
+ previewSrc: null,
2999
+ uploadError: true
3000
+ });
2875
3001
  this.#revokePreviewSrc(previewSrc);
2876
3002
  }
2877
3003
 
2878
- get #backgroundUpdateTags() {
2879
- const rootElement = this.editor.getRootElement();
2880
- const editorHasFocus = rootElement !== null && rootElement.contains(document.activeElement);
2881
-
2882
- if (editorHasFocus) {
2883
- return SILENT_UPDATE_TAGS
2884
- } else {
2885
- return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
2886
- }
2887
- }
2888
-
2889
3004
  #revokePreviewSrc(previewSrc) {
2890
3005
  if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
2891
3006
  }
@@ -2947,9 +3062,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2947
3062
  figure.appendChild(this.#createEditableCaption());
2948
3063
  });
2949
3064
 
2950
- this.editor.update(() => {
2951
- if (this.isAttached()) this.getWritable().pendingPreview = false;
2952
- }, { tag: this.#backgroundUpdateTags });
3065
+ this.patchAndRewriteHistory({ pendingPreview: false });
2953
3066
  }
2954
3067
 
2955
3068
  #swapFigureContent(figure, fromClass, toClass, renderContent) {
@@ -3064,12 +3177,6 @@ class Selection {
3064
3177
  this.#clearStaleInlineCodeFormat();
3065
3178
  }
3066
3179
 
3067
- set current(selection) {
3068
- this.editor.update(() => {
3069
- this.#syncSelectedClasses();
3070
- });
3071
- }
3072
-
3073
3180
  get hasNodeSelection() {
3074
3181
  return this.editor.getEditorState().read(() => {
3075
3182
  const selection = $getSelection();
@@ -3398,7 +3505,7 @@ class Selection {
3398
3505
  this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),
3399
3506
 
3400
3507
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3401
- this.current = $getSelection();
3508
+ this.#syncSelectedClasses();
3402
3509
  }, COMMAND_PRIORITY_LOW)
3403
3510
  );
3404
3511
  }
@@ -3596,6 +3703,7 @@ class Selection {
3596
3703
 
3597
3704
  #selectInLexical(node) {
3598
3705
  if ($isDecoratorNode(node)) {
3706
+ $addUpdateTag(HISTORY_MERGE_TAG);
3599
3707
  const selection = $createNodeSelectionWith(node);
3600
3708
  $setSelection(selection);
3601
3709
  return selection
@@ -3961,107 +4069,6 @@ class EditorConfiguration {
3961
4069
  }
3962
4070
  }
3963
4071
 
3964
- class ProvisionalParagraphNode extends ParagraphNode {
3965
- $config() {
3966
- return this.config("provisonal_paragraph", {
3967
- extends: ParagraphNode,
3968
- importDOM: () => null,
3969
- $transform: (node) => {
3970
- node.concretizeIfEdited(node);
3971
- node.removeUnlessRequired(node);
3972
- }
3973
- })
3974
- }
3975
-
3976
- static neededBetween(nodeBefore, nodeAfter) {
3977
- return !$isSelectableElement(nodeBefore, "next")
3978
- && !$isSelectableElement(nodeAfter, "previous")
3979
- }
3980
-
3981
- createDOM(editor) {
3982
- const p = super.createDOM(editor);
3983
- const selected = this.isSelected($getSelection());
3984
- p.classList.add("provisional-paragraph");
3985
- p.classList.toggle("hidden", !selected);
3986
- return p
3987
- }
3988
-
3989
- updateDOM(_prevNode, dom) {
3990
- const selected = this.isSelected($getSelection());
3991
- dom.classList.toggle("hidden", !selected);
3992
- return false
3993
- }
3994
-
3995
- getTextContent() {
3996
- return ""
3997
- }
3998
-
3999
- exportDOM() {
4000
- return {
4001
- element: null
4002
- }
4003
- }
4004
-
4005
- // override as Lexical has an interesting view of collapsed selection in ElementNodes
4006
- // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
4007
- isSelected(selection = null) {
4008
- const targetSelection = selection || $getSelection();
4009
- if (!targetSelection) return false
4010
-
4011
- if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
4012
-
4013
- // A collapsed range selection on the parent element at an offset adjacent to
4014
- // this node means the caret is visually at this paragraph's position. Treat it
4015
- // as selected so the paragraph is visible and the caret renders correctly.
4016
- //
4017
- // Both the offset matching our index (cursor just before us) and index + 1
4018
- // (cursor just after us) count, because the provisional paragraph is an
4019
- // invisible spacer: the browser resolves both offsets to the same visual spot.
4020
- if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
4021
- const { anchor } = targetSelection;
4022
- const parent = this.getParent();
4023
- if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
4024
- const index = this.getIndexWithinParent();
4025
- return anchor.offset === index || anchor.offset === index + 1
4026
- }
4027
- }
4028
-
4029
- return false
4030
- }
4031
-
4032
- removeUnlessRequired(self = this.getLatest()) {
4033
- if (!self.required) self.remove();
4034
- }
4035
-
4036
- concretizeIfEdited(self = this.getLatest()) {
4037
- if (self.getTextContentSize() > 0) {
4038
- self.replace($createParagraphNode(), true);
4039
- }
4040
- }
4041
-
4042
-
4043
- get required() {
4044
- return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
4045
- }
4046
-
4047
- get isDirectRootChild() {
4048
- const parent = this.getParent();
4049
- return $isRootOrShadowRoot(parent)
4050
- }
4051
-
4052
- get immediateSiblings() {
4053
- return [ this.getPreviousSibling(), this.getNextSibling() ]
4054
- }
4055
- }
4056
-
4057
- function $isProvisionalParagraphNode(node) {
4058
- return node instanceof ProvisionalParagraphNode
4059
- }
4060
-
4061
- function $isSelectableElement(node, direction) {
4062
- return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
4063
- }
4064
-
4065
4072
  async function loadFileIntoImage(file, image) {
4066
4073
  return new Promise((resolve) => {
4067
4074
  const reader = new FileReader();
@@ -4097,10 +4104,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4097
4104
  }
4098
4105
 
4099
4106
  constructor(node, key) {
4100
- const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node;
4101
- super({ ...node, contentType: file.type }, key);
4102
- this.file = file;
4103
- this.fileName = file.name;
4107
+ const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node;
4108
+ super({ ...node, contentType: file?.type ?? contentType }, key);
4109
+ this.file = file ?? null;
4110
+ this.fileName = file?.name ?? fileName;
4104
4111
  this.uploadUrl = uploadUrl;
4105
4112
  this.blobUrlTemplate = blobUrlTemplate;
4106
4113
  this.progress = progress ?? null;
@@ -4157,6 +4164,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4157
4164
  ...super.exportJSON(),
4158
4165
  type: "action_text_attachment_upload",
4159
4166
  version: 1,
4167
+ fileName: this.fileName,
4168
+ contentType: this.contentType,
4160
4169
  uploadUrl: this.uploadUrl,
4161
4170
  blobUrlTemplate: this.blobUrlTemplate,
4162
4171
  progress: this.progress,
@@ -4181,14 +4190,14 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4181
4190
  }
4182
4191
 
4183
4192
  #getFileExtension() {
4184
- return this.file.name.split(".").pop().toLowerCase()
4193
+ return (this.fileName || "").split(".").pop().toLowerCase()
4185
4194
  }
4186
4195
 
4187
4196
  #createCaption() {
4188
4197
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
4189
4198
 
4190
- const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" });
4191
- const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) });
4199
+ const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" });
4200
+ const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) });
4192
4201
  figcaption.appendChild(nameSpan);
4193
4202
  figcaption.appendChild(sizeSpan);
4194
4203
 
@@ -4202,11 +4211,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4202
4211
  #setDimensionsFromImage({ width, height }) {
4203
4212
  if (this.#hasDimensions) return
4204
4213
 
4205
- this.editor.update(() => {
4206
- const writable = this.getWritable();
4207
- writable.width = width;
4208
- writable.height = height;
4209
- }, { tag: this.#backgroundUpdateTags });
4214
+ this.patchAndRewriteHistory({ width, height });
4210
4215
  }
4211
4216
 
4212
4217
  get #hasDimensions() {
@@ -4233,8 +4238,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4233
4238
  } else {
4234
4239
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
4235
4240
  this.editor.update(() => {
4236
- this.showUploadedAttachment(blob);
4237
- }, { tag: this.#backgroundUpdateTags });
4241
+ this.$showUploadedAttachment(blob);
4242
+ });
4238
4243
  }
4239
4244
  });
4240
4245
  }
@@ -4249,7 +4254,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4249
4254
  directUploadWillStoreFileWithXHR: (request) => {
4250
4255
  if (shouldAuthenticateUploads) request.withCredentials = true;
4251
4256
 
4252
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
4257
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
4253
4258
  request.upload.addEventListener("progress", uploadProgressHandler);
4254
4259
  }
4255
4260
  }
@@ -4259,70 +4264,35 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4259
4264
  this.#setProgress(1);
4260
4265
  }
4261
4266
 
4262
- #handleUploadProgress(event) {
4267
+ #handleUploadProgress(event, request) {
4263
4268
  const progress = Math.round(event.loaded / event.total * 100);
4264
- this.#setProgress(progress);
4265
- this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4269
+ try {
4270
+ this.#setProgress(progress);
4271
+ this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress });
4272
+ } catch {
4273
+ request.abort();
4274
+ }
4266
4275
  }
4267
4276
 
4268
4277
  #setProgress(progress) {
4269
- this.editor.update(() => {
4270
- this.getWritable().progress = progress;
4271
- }, { tag: this.#backgroundUpdateTags });
4278
+ this.patchAndRewriteHistory({ progress });
4272
4279
  }
4273
4280
 
4274
4281
  #handleUploadError(error) {
4275
4282
  console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4276
- this.editor.update(() => {
4277
- this.getWritable().uploadError = true;
4278
- }, { tag: this.#backgroundUpdateTags });
4283
+
4284
+ this.patchAndRewriteHistory({ uploadError: true });
4279
4285
  }
4280
4286
 
4281
- showUploadedAttachment(blob) {
4287
+ $showUploadedAttachment(blob) {
4282
4288
  const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4283
4289
 
4284
4290
  const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc);
4285
- const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode;
4286
- this.replace(replacementNode);
4287
-
4288
- if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) {
4289
- replacementNode.selectNext();
4290
- }
4291
+ this.replaceAndRewriteHistory(replacementNode);
4291
4292
 
4292
4293
  return replacementNode.getKey()
4293
4294
  }
4294
4295
 
4295
- // Upload lifecycle methods (progress, completion, errors) run asynchronously and may
4296
- // fire while the user is focused on another element (e.g., a title field). Without
4297
- // SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
4298
- // the editor, stealing focus from wherever the user is currently typing.
4299
- get #backgroundUpdateTags() {
4300
- if (this.#editorHasFocus) {
4301
- return SILENT_UPDATE_TAGS
4302
- } else {
4303
- return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ]
4304
- }
4305
- }
4306
-
4307
- get #editorHasFocus() {
4308
- const rootElement = this.editor.getRootElement();
4309
- return rootElement !== null && rootElement.contains(document.activeElement)
4310
- }
4311
-
4312
- get #selectionIncludesUploadNode() {
4313
- const selection = $getSelection();
4314
- if (selection === null) return false
4315
-
4316
- if (selection.getNodes().some((node) => node.is(this))) return true
4317
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false
4318
-
4319
- const anchorNode = selection.anchor.getNode();
4320
- if (!$isProvisionalParagraphNode(anchorNode) || !anchorNode.isEmpty()) return false
4321
-
4322
- const previousSibling = anchorNode.getPreviousSibling();
4323
- return previousSibling !== null && previousSibling.is(this)
4324
- }
4325
-
4326
4296
  #toActionTextAttachmentNodeWith(blob, previewSrc) {
4327
4297
  const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
4328
4298
  return conversion.toAttachmentNode()
@@ -4672,34 +4642,137 @@ class GalleryUploader extends Uploader {
4672
4642
  }
4673
4643
  }
4674
4644
 
4675
- class Contents {
4676
- constructor(editorElement) {
4677
- this.editorElement = editorElement;
4678
- this.editor = editorElement.editor;
4645
+ class NodeInserter {
4646
+ static for(selection) {
4647
+ const INSERTERS = [
4648
+ CodeNodeInserter,
4649
+ QuoteNodeInserter,
4650
+ ShadowRootNodeInserter,
4651
+ NodeSelectionNodeInserter
4652
+ ];
4653
+ const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
4654
+ return Inserter ? new Inserter(selection) : selection
4679
4655
  }
4680
4656
 
4681
- dispose() {
4682
- this.editorElement = null;
4683
- this.editor = null;
4657
+ constructor(selection) {
4658
+ this.selection = selection;
4684
4659
  }
4660
+ }
4685
4661
 
4686
- get selection() { return this.editorElement.selection }
4687
-
4688
- insertHtml(html, { tag } = {}) {
4689
- this.insertDOM(parseHtml(html), { tag });
4662
+ class CodeNodeInserter extends NodeInserter {
4663
+ static handles(selection) {
4664
+ return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
4690
4665
  }
4691
4666
 
4692
- insertDOM(doc, { tag } = {}) {
4693
- this.#unwrapPlaceholderAnchors(doc);
4667
+ insertNodes(nodes) {
4668
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4694
4669
 
4695
- this.editor.update(() => {
4696
- if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4670
+ $ensureForwardRangeSelection(this.selection);
4671
+ const focusNode = this.selection.focus.getNode();
4672
+ const codeNode = $getNearestNodeOfType(focusNode, CodeNode);
4673
+ const insertionIndex = focusNode.is(codeNode) ? 0 : focusNode.getIndexWithinParent();
4697
4674
 
4698
- const nodes = $generateNodesFromDOM(this.editor, doc);
4699
- if (!this.#insertUploadNodes(nodes)) {
4700
- this.insertAtCursor(...nodes);
4701
- }
4702
- }, { tag });
4675
+ const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
4676
+
4677
+ for (const node of nodes) {
4678
+ if (!node.isAttached()) continue
4679
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
4680
+
4681
+ caret.insert(this.#convertNodeToCodeChild(node));
4682
+ }
4683
+
4684
+ caret.getNodeAtCaret().selectEnd();
4685
+ }
4686
+
4687
+ #convertNodeToCodeChild(node) {
4688
+ if ($isLineBreakNode(node)) {
4689
+ return node
4690
+ } else {
4691
+ node.remove();
4692
+ return $createTextNode(node.getTextContent())
4693
+ }
4694
+ }
4695
+
4696
+ }
4697
+
4698
+ // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
4699
+ class QuoteNodeInserter extends NodeInserter {
4700
+ static handles(selection) {
4701
+ return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
4702
+ }
4703
+
4704
+ insertNodes(nodes) {
4705
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4706
+
4707
+ $ensureForwardRangeSelection(this.selection);
4708
+ let lastNode = this.selection.focus.getNode();
4709
+ for (const node of nodes) {
4710
+ lastNode = lastNode.insertAfter(node);
4711
+ }
4712
+
4713
+ lastNode.selectEnd();
4714
+ }
4715
+ }
4716
+
4717
+ class ShadowRootNodeInserter extends NodeInserter {
4718
+ static handles(selection) {
4719
+ return $isShadowRoot(selection?.anchor.getNode())
4720
+ }
4721
+
4722
+ insertNodes(nodes) {
4723
+ const anchorNode = this.selection.anchor.getNode();
4724
+ const paragraph = $createParagraphNode();
4725
+ anchorNode.append(paragraph);
4726
+
4727
+ paragraph.selectStart().insertNodes(nodes);
4728
+ }
4729
+ }
4730
+
4731
+ class NodeSelectionNodeInserter extends NodeInserter {
4732
+ static handles(selection) {
4733
+ return $isNodeSelection(selection)
4734
+ }
4735
+
4736
+ insertNodes(nodes) {
4737
+ const selectedNodes = this.selection.getNodes();
4738
+
4739
+ // Overrides Lexical's default behavior of _removing_ the currently selected nodes
4740
+ // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
4741
+ let lastNode = selectedNodes.at(-1);
4742
+ for (const node of nodes) {
4743
+ lastNode = lastNode.insertAfter(node);
4744
+ }
4745
+ }
4746
+ }
4747
+
4748
+ class Contents {
4749
+ constructor(editorElement) {
4750
+ this.editorElement = editorElement;
4751
+ this.editor = editorElement.editor;
4752
+ }
4753
+
4754
+ dispose() {
4755
+ this.editorElement = null;
4756
+ this.editor = null;
4757
+ }
4758
+
4759
+ get selection() { return this.editorElement.selection }
4760
+
4761
+ insertHtml(html, { tag } = {}) {
4762
+ this.insertDOM(parseHtml(html), { tag });
4763
+ }
4764
+
4765
+ insertDOM(doc, { tag } = {}) {
4766
+ this.#unwrapPlaceholderAnchors(doc);
4767
+
4768
+ this.editor.update(() => {
4769
+ if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4770
+
4771
+ const nodes = $generateNodesFromDOM(this.editor, doc);
4772
+ if (!this.#insertUploadNodes(nodes)) {
4773
+ this.insertAtCursor(...nodes);
4774
+ }
4775
+ }, { tag });
4703
4776
  }
4704
4777
 
4705
4778
  insertAtCursor(...nodes) {
@@ -4942,7 +5015,7 @@ class Contents {
4942
5015
  const node = $getNodeByKey(nodeKey);
4943
5016
  if (!(node instanceof ActionTextAttachmentUploadNode)) return
4944
5017
 
4945
- const replacementNodeKey = node.showUploadedAttachment(blob);
5018
+ const replacementNodeKey = node.$showUploadedAttachment(blob);
4946
5019
  if (replacementNodeKey) {
4947
5020
  nodeKey = replacementNodeKey;
4948
5021
  }
@@ -5281,113 +5354,6 @@ class Contents {
5281
5354
  }
5282
5355
  }
5283
5356
 
5284
- function $isShadowRoot(node) {
5285
- return $isElementNode(node) && $isRootOrShadowRoot(node) && !$isRootNode(node)
5286
- }
5287
-
5288
- class NodeInserter {
5289
- static for(selection) {
5290
- const INSERTERS = [
5291
- CodeNodeInserter,
5292
- QuoteNodeInserter,
5293
- ShadowRootNodeInserter,
5294
- NodeSelectionNodeInserter
5295
- ];
5296
- const Inserter = INSERTERS.find(inserter => inserter.handles(selection));
5297
- return Inserter ? new Inserter(selection) : selection
5298
- }
5299
-
5300
- constructor(selection) {
5301
- this.selection = selection;
5302
- }
5303
- }
5304
-
5305
- class CodeNodeInserter extends NodeInserter {
5306
- static handles(selection) {
5307
- return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
5308
- }
5309
-
5310
- insertNodes(nodes) {
5311
- if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5312
-
5313
- $ensureForwardRangeSelection(this.selection);
5314
- const focusNode = this.selection.focus.getNode();
5315
- const codeNode = $getNearestNodeOfType(focusNode, CodeNode);
5316
- const insertionIndex = focusNode.is(codeNode) ? 0 : focusNode.getIndexWithinParent();
5317
-
5318
- const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
5319
-
5320
- for (const node of nodes) {
5321
- if (!node.isAttached()) continue
5322
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()); }
5323
-
5324
- caret.insert(this.#convertNodeToCodeChild(node));
5325
- }
5326
-
5327
- caret.getNodeAtCaret().selectEnd();
5328
- }
5329
-
5330
- #convertNodeToCodeChild(node) {
5331
- if ($isLineBreakNode(node)) {
5332
- return node
5333
- } else {
5334
- node.remove();
5335
- return $createTextNode(node.getTextContent())
5336
- }
5337
- }
5338
-
5339
- }
5340
-
5341
- // Lexical will split a QuoteNode when inserting other Elements - we want them simply inserted as-is
5342
- class QuoteNodeInserter extends NodeInserter {
5343
- static handles(selection) {
5344
- return $getNearestNodeOfType(selection.anchor?.getNode(), QuoteNode)
5345
- }
5346
-
5347
- insertNodes(nodes) {
5348
- if (!this.selection.isCollapsed()) { this.selection.removeText(); }
5349
-
5350
- $ensureForwardRangeSelection(this.selection);
5351
- let lastNode = this.selection.focus.getNode();
5352
- for (const node of nodes) {
5353
- lastNode = lastNode.insertAfter(node);
5354
- }
5355
-
5356
- lastNode.selectEnd();
5357
- }
5358
- }
5359
-
5360
- class ShadowRootNodeInserter extends NodeInserter {
5361
- static handles(selection) {
5362
- return $isShadowRoot(selection?.anchor.getNode())
5363
- }
5364
-
5365
- insertNodes(nodes) {
5366
- const anchorNode = this.selection.anchor.getNode();
5367
- const paragraph = $createParagraphNode();
5368
- anchorNode.append(paragraph);
5369
-
5370
- paragraph.selectStart().insertNodes(nodes);
5371
- }
5372
- }
5373
-
5374
- class NodeSelectionNodeInserter extends NodeInserter {
5375
- static handles(selection) {
5376
- return $isNodeSelection(selection)
5377
- }
5378
-
5379
- insertNodes(nodes) {
5380
- const selectedNodes = this.selection.getNodes();
5381
-
5382
- // Overrides Lexical's default behavior of _removing_ the currently selected nodes
5383
- // https://github.com/facebook/lexical/blob/v0.38.2/packages/lexical/src/LexicalSelection.ts#L412
5384
- let lastNode = selectedNodes.at(-1);
5385
- for (const node of nodes) {
5386
- lastNode = lastNode.insertAfter(node);
5387
- }
5388
- }
5389
- }
5390
-
5391
5357
  class Clipboard {
5392
5358
  constructor(editorElement) {
5393
5359
  this.editorElement = editorElement;
@@ -5694,6 +5660,107 @@ function unwrapSpans(element) {
5694
5660
  return element
5695
5661
  }
5696
5662
 
5663
+ class ProvisionalParagraphNode extends ParagraphNode {
5664
+ $config() {
5665
+ return this.config("provisonal_paragraph", {
5666
+ extends: ParagraphNode,
5667
+ importDOM: () => null,
5668
+ $transform: (node) => {
5669
+ node.concretizeIfEdited(node);
5670
+ node.removeUnlessRequired(node);
5671
+ }
5672
+ })
5673
+ }
5674
+
5675
+ static neededBetween(nodeBefore, nodeAfter) {
5676
+ return !$isSelectableElement(nodeBefore, "next")
5677
+ && !$isSelectableElement(nodeAfter, "previous")
5678
+ }
5679
+
5680
+ createDOM(editor) {
5681
+ const p = super.createDOM(editor);
5682
+ const selected = this.isSelected($getSelection());
5683
+ p.classList.add("provisional-paragraph");
5684
+ p.classList.toggle("hidden", !selected);
5685
+ return p
5686
+ }
5687
+
5688
+ updateDOM(_prevNode, dom) {
5689
+ const selected = this.isSelected($getSelection());
5690
+ dom.classList.toggle("hidden", !selected);
5691
+ return false
5692
+ }
5693
+
5694
+ getTextContent() {
5695
+ return ""
5696
+ }
5697
+
5698
+ exportDOM() {
5699
+ return {
5700
+ element: null
5701
+ }
5702
+ }
5703
+
5704
+ // override as Lexical has an interesting view of collapsed selection in ElementNodes
5705
+ // https://github.com/facebook/lexical/blob/f1e4f66014377b1f2595aec2b0ee17f5b7ef4dfc/packages/lexical/src/LexicalNode.ts#L646
5706
+ isSelected(selection = null) {
5707
+ const targetSelection = selection || $getSelection();
5708
+ if (!targetSelection) return false
5709
+
5710
+ if (targetSelection.getNodes().some(node => node.is(this) || this.isParentOf(node))) return true
5711
+
5712
+ // A collapsed range selection on the parent element at an offset adjacent to
5713
+ // this node means the caret is visually at this paragraph's position. Treat it
5714
+ // as selected so the paragraph is visible and the caret renders correctly.
5715
+ //
5716
+ // Both the offset matching our index (cursor just before us) and index + 1
5717
+ // (cursor just after us) count, because the provisional paragraph is an
5718
+ // invisible spacer: the browser resolves both offsets to the same visual spot.
5719
+ if ($isRangeSelection(targetSelection) && targetSelection.isCollapsed()) {
5720
+ const { anchor } = targetSelection;
5721
+ const parent = this.getParent();
5722
+ if (parent && anchor.getNode().is(parent) && anchor.type === "element") {
5723
+ const index = this.getIndexWithinParent();
5724
+ return anchor.offset === index || anchor.offset === index + 1
5725
+ }
5726
+ }
5727
+
5728
+ return false
5729
+ }
5730
+
5731
+ removeUnlessRequired(self = this.getLatest()) {
5732
+ if (!self.required) self.remove();
5733
+ }
5734
+
5735
+ concretizeIfEdited(self = this.getLatest()) {
5736
+ if (self.getTextContentSize() > 0) {
5737
+ self.replace($createParagraphNode(), true);
5738
+ }
5739
+ }
5740
+
5741
+
5742
+ get required() {
5743
+ return this.isDirectRootChild && ProvisionalParagraphNode.neededBetween(...this.immediateSiblings)
5744
+ }
5745
+
5746
+ get isDirectRootChild() {
5747
+ const parent = this.getParent();
5748
+ return $isRootOrShadowRoot(parent)
5749
+ }
5750
+
5751
+ get immediateSiblings() {
5752
+ return [ this.getPreviousSibling(), this.getNextSibling() ]
5753
+ }
5754
+ }
5755
+
5756
+ function $isProvisionalParagraphNode(node) {
5757
+ return node instanceof ProvisionalParagraphNode
5758
+ }
5759
+
5760
+ function $isSelectableElement(node, direction) {
5761
+ return $isElementNode(node) && (direction === "next" ? node.canInsertTextBefore() : node.canInsertTextAfter())
5762
+ }
5763
+
5697
5764
  class ProvisionalParagraphExtension extends LexxyExtension {
5698
5765
  get lexicalExtension() {
5699
5766
  return defineExtension({
@@ -5715,6 +5782,8 @@ class ProvisionalParagraphExtension extends LexxyExtension {
5715
5782
  }
5716
5783
 
5717
5784
  function $insertRequiredProvisionalParagraphs(rootNode) {
5785
+ const nodeBeforeRootSelection = $nodeBeforeRootSelection(rootNode);
5786
+
5718
5787
  const firstNode = rootNode.getFirstChild();
5719
5788
  if (ProvisionalParagraphNode.neededBetween(null, firstNode)) {
5720
5789
  $insertFirst(rootNode, new ProvisionalParagraphNode);
@@ -5724,10 +5793,18 @@ function $insertRequiredProvisionalParagraphs(rootNode) {
5724
5793
  const nextNode = node.getNextSibling();
5725
5794
  if (ProvisionalParagraphNode.neededBetween(node, nextNode)) {
5726
5795
  node.insertAfter(new ProvisionalParagraphNode);
5796
+ if (node.is(nodeBeforeRootSelection)) node.selectNext();
5727
5797
  }
5728
5798
  }
5729
5799
  }
5730
5800
 
5801
+ function $nodeBeforeRootSelection(rootNode) {
5802
+ const selection = $getSelection();
5803
+ if (!$isRootOrShadowRoot(selection?.anchor?.getNode())) return null
5804
+
5805
+ return rootNode.getChildAtIndex(selection.anchor.offset - 1)
5806
+ }
5807
+
5731
5808
  function $removeUnneededProvisionalParagraphs(rootNode) {
5732
5809
  for (const provisionalParagraph of $getAllProvisionalParagraphs(rootNode)) {
5733
5810
  provisionalParagraph.removeUnlessRequired();
@@ -5735,6 +5812,9 @@ function $removeUnneededProvisionalParagraphs(rootNode) {
5735
5812
  }
5736
5813
 
5737
5814
  function $markAllProvisionalParagraphsDirty() {
5815
+ // Selection-driven visibility updates must not become standalone undo steps.
5816
+ $addUpdateTag(HISTORY_MERGE_TAG);
5817
+
5738
5818
  for (const provisionalParagraph of $getAllProvisionalParagraphs()) {
5739
5819
  provisionalParagraph.markDirty();
5740
5820
  }
@@ -6615,54 +6695,58 @@ class LinkOpenerExtension extends LexxyExtension {
6615
6695
  get lexicalExtension() {
6616
6696
  return defineExtension({
6617
6697
  name: "lexxy/link-opener",
6618
- register: () => {
6619
- return mergeRegister(
6620
- registerEventListener(window, "keydown", this.#update.bind(this)),
6621
- registerEventListener(window, "keyup", this.#update.bind(this)),
6622
- registerEventListener(window, "blur", this.#disable.bind(this)),
6623
- registerEventListener(window, "focus", this.#refresh.bind(this))
6624
- )
6625
- }
6698
+ register: (editor) => mergeRegister$1(
6699
+ editor.registerCommand(CLICK_COMMAND, this.#handleClick.bind(this), COMMAND_PRIORITY_NORMAL),
6700
+ registerEventListener(this.editorElement.editorContentElement, "auxclick", this.#handleAuxClick.bind(this)),
6701
+ registerEventListener(window, "keydown", this.#handleKey.bind(this)),
6702
+ registerEventListener(window, "keyup", this.#handleKey.bind(this)),
6703
+ registerEventListener(window, "focus", this.#handleFocus.bind(this))
6704
+ )
6626
6705
  })
6627
6706
  }
6628
6707
 
6629
- #update(event) {
6708
+ #handleClick(event) {
6630
6709
  if (this.#isModified(event)) {
6631
- this.#enable();
6710
+ return $openLink(event.target)
6632
6711
  } else {
6633
- this.#disable();
6712
+ return false
6634
6713
  }
6635
6714
  }
6636
6715
 
6637
- #refresh() {
6638
- // Chrome dispatches events without modifier keys *for a while* after changing tabs
6639
- setTimeout(() => {
6640
- window.addEventListener("mousemove", this.#update.bind(this), { once: true });
6641
- }, 200);
6716
+ #handleAuxClick(event) {
6717
+ if (event.button === 1) {
6718
+ this.editorElement.editor.read(() => $openLink(event.target));
6719
+ }
6642
6720
  }
6643
6721
 
6644
- #isModified(event) {
6645
- return IS_APPLE ? event.metaKey : event.ctrlKey
6722
+ #handleKey(event) {
6723
+ this.#updateOpenableAttribute(event);
6646
6724
  }
6647
6725
 
6648
- #enable() {
6649
- for (const anchor of this.#anchors) {
6650
- anchor.setAttribute("contenteditable", "false");
6651
- anchor.setAttribute("target", "_blank");
6652
- anchor.setAttribute("rel", "noopener noreferrer");
6653
- }
6726
+ // Chrome dispatches events without modifier keys *for a while* after changing tabs
6727
+ async #handleFocus() {
6728
+ await delay(200);
6729
+ this.editorElement.addEventListener("mousemove", this.#updateOpenableAttribute.bind(this), { once: true });
6654
6730
  }
6655
6731
 
6656
- #disable() {
6657
- for (const anchor of this.#anchors) {
6658
- anchor.removeAttribute("contenteditable");
6659
- anchor.removeAttribute("target");
6660
- anchor.removeAttribute("rel");
6661
- }
6732
+ #updateOpenableAttribute(event) {
6733
+ this.editorElement.toggleAttribute("data-links-openable", this.#isModified(event));
6662
6734
  }
6663
6735
 
6664
- get #anchors() {
6665
- return this.editorElement.editorContentElement?.querySelectorAll("a") ?? []
6736
+ #isModified(event) {
6737
+ return IS_APPLE ? event.metaKey : event.ctrlKey
6738
+ }
6739
+ }
6740
+
6741
+ function $openLink(target) {
6742
+ const node = $getNearestNodeFromDOMNode(target);
6743
+ const linkNode = $findMatchingParent(node, $isLinkNode);
6744
+ if (linkNode) {
6745
+ const url = linkNode.sanitizeUrl(linkNode.getURL());
6746
+ window.open(url, "_blank", "noopener,noreferrer");
6747
+ return true
6748
+ } else {
6749
+ return false
6666
6750
  }
6667
6751
  }
6668
6752
 
@@ -6674,11 +6758,11 @@ class LexicalEditorElement extends HTMLElement {
6674
6758
  static observedAttributes = [ "connected", "required" ]
6675
6759
 
6676
6760
  #initialValue = ""
6677
- #initialValueLoaded = false
6678
6761
  #validationTextArea = document.createElement("textarea")
6679
6762
  #editorInitializedRafId = null
6680
6763
  #listeners = new ListenerBin()
6681
6764
  #disposables = []
6765
+ #historyState = { undo: false, redo: false }
6682
6766
 
6683
6767
  constructor() {
6684
6768
  super();
@@ -6770,6 +6854,7 @@ class LexicalEditorElement extends HTMLElement {
6770
6854
  HighlightExtension,
6771
6855
  TrixContentExtension,
6772
6856
  TablesExtension,
6857
+ RewritableHistoryExtension,
6773
6858
  AttachmentsExtension,
6774
6859
  FormatEscapeExtension,
6775
6860
  LinkOpenerExtension
@@ -6876,28 +6961,26 @@ class LexicalEditorElement extends HTMLElement {
6876
6961
  }
6877
6962
 
6878
6963
  set value(html) {
6879
- const wasEmpty = !this.#initialValueLoaded;
6964
+ const editorHasFocus = isEditorFocused(this.editor);
6880
6965
 
6881
6966
  this.editor.update(() => {
6882
- $addUpdateTag(SKIP_DOM_SELECTION_TAG);
6883
- const root = $getRoot();
6884
- root.clear();
6885
- root.append(...this.#parseHtmlIntoLexicalNodes(html));
6886
- root.selectEnd();
6967
+ if (!editorHasFocus) $addUpdateTag(SKIP_DOM_SELECTION_TAG);
6968
+
6969
+ $getRoot()
6970
+ .clear()
6971
+ .selectEnd()
6972
+ .insertNodes(this.#parseHtmlIntoLexicalNodes(html));
6887
6973
 
6888
6974
  this.#toggleEmptyStatus();
6975
+ }, { discrete: true });
6976
+ }
6889
6977
 
6890
- // The first time you set the value on an empty editor, Lexical can be
6891
- // left in an inconsistent state until the next update (adding attachments
6892
- // fails because no root node is detected). A no-op update works around
6893
- // it. Only fire on the first load — subsequent set value calls don't hit
6894
- // the inconsistent state and the extra reconciler cycle is pure overhead.
6895
- if (wasEmpty) {
6896
- requestAnimationFrame(() => this.editor?.update(() => { }));
6897
- }
6898
- });
6978
+ get canUndo() {
6979
+ return this.#historyState.undo
6980
+ }
6899
6981
 
6900
- this.#initialValueLoaded = true;
6982
+ get canRedo() {
6983
+ return this.#historyState.redo
6901
6984
  }
6902
6985
 
6903
6986
  #parseHtmlIntoLexicalNodes(html) {
@@ -6933,6 +7016,7 @@ class LexicalEditorElement extends HTMLElement {
6933
7016
  this.#registerComponents();
6934
7017
  this.#handleEnter();
6935
7018
  this.#registerFocusEvents();
7019
+ this.#registerHistoryEvents();
6936
7020
  this.#attachDebugHooks();
6937
7021
  this.#attachToolbar();
6938
7022
  this.#configureSanitizer();
@@ -7029,8 +7113,10 @@ class LexicalEditorElement extends HTMLElement {
7029
7113
  }
7030
7114
 
7031
7115
  #loadInitialValue() {
7032
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p></p>";
7033
- this.value = this.#initialValue = initialHtml;
7116
+ const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>";
7117
+ this.editor.update(() => {
7118
+ this.value = this.#initialValue = initialHtml;
7119
+ }, { tag: HISTORY_MERGE_TAG });
7034
7120
  }
7035
7121
 
7036
7122
  #resetBeforeTurboCaches() {
@@ -7080,8 +7166,6 @@ class LexicalEditorElement extends HTMLElement {
7080
7166
  } else {
7081
7167
  registered.push(registerPlainText(this.editor));
7082
7168
  }
7083
- this.historyState = createEmptyHistoryState();
7084
- registered.push(registerHistory(this.editor, this.historyState, 20));
7085
7169
 
7086
7170
  this.#listeners.track(...registered);
7087
7171
  }
@@ -7164,6 +7248,12 @@ class LexicalEditorElement extends HTMLElement {
7164
7248
  }
7165
7249
  }
7166
7250
 
7251
+ #registerHistoryEvents() {
7252
+ this.#listeners.track(
7253
+ this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#historyState.undo = enabled; }, COMMAND_PRIORITY_NORMAL),
7254
+ this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#historyState.redo = enabled; }, COMMAND_PRIORITY_NORMAL)
7255
+ );
7256
+ }
7167
7257
 
7168
7258
  #attachDebugHooks() {
7169
7259
  return
@@ -7254,8 +7344,8 @@ class LexicalEditorElement extends HTMLElement {
7254
7344
  heading: { active: format.isInHeading, enabled: true },
7255
7345
  "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
7256
7346
  "ordered-list": { active: format.isInList && format.listType === "number", enabled: true },
7257
- undo: { active: false, enabled: this.historyState?.undoStack.length > 0 },
7258
- redo: { active: false, enabled: this.historyState?.redoStack.length > 0 }
7347
+ undo: { active: false, enabled: this.canUndo },
7348
+ redo: { active: false, enabled: this.canRedo }
7259
7349
  };
7260
7350
 
7261
7351
  linkHref = linkNode ? linkNode.getURL() : null;
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.9-beta-preview1",
3
+ "version": "0.9.9-beta.preview3.domselection",
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
  },