@37signals/lexxy 0.9.9-beta-preview1 → 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)
@@ -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,109 @@ 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
+ $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
+
2622
2725
  class ActionTextAttachmentNode extends DecoratorNode {
2623
2726
  static getType() {
2624
2727
  return "action_text_attachment"
@@ -2831,6 +2934,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2831
2934
  return figure
2832
2935
  }
2833
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
+
2834
2949
  #createDOMForImage(options = {}) {
2835
2950
  const initialSrc = this.previewSrc || this.src;
2836
2951
  const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options });
@@ -2859,33 +2974,18 @@ class ActionTextAttachmentNode extends DecoratorNode {
2859
2974
 
2860
2975
  #handleImageLoaded(img, previewSrc) {
2861
2976
  img.src = this.src;
2862
- this.editor.update(() => {
2863
- if (this.isAttached()) this.getWritable().previewSrc = null;
2864
- }, { tag: this.#backgroundUpdateTags });
2977
+ this.patchAndRewriteHistory({ previewSrc: null });
2865
2978
  this.#revokePreviewSrc(previewSrc);
2866
2979
  }
2867
2980
 
2868
2981
  #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 });
2982
+ this.patchAndRewriteHistory({
2983
+ previewSrc: null,
2984
+ uploadError: true
2985
+ });
2875
2986
  this.#revokePreviewSrc(previewSrc);
2876
2987
  }
2877
2988
 
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
2989
  #revokePreviewSrc(previewSrc) {
2890
2990
  if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc);
2891
2991
  }
@@ -2947,9 +3047,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2947
3047
  figure.appendChild(this.#createEditableCaption());
2948
3048
  });
2949
3049
 
2950
- this.editor.update(() => {
2951
- if (this.isAttached()) this.getWritable().pendingPreview = false;
2952
- }, { tag: this.#backgroundUpdateTags });
3050
+ this.patchAndRewriteHistory({ pendingPreview: false });
2953
3051
  }
2954
3052
 
2955
3053
  #swapFigureContent(figure, fromClass, toClass, renderContent) {
@@ -3064,12 +3162,6 @@ class Selection {
3064
3162
  this.#clearStaleInlineCodeFormat();
3065
3163
  }
3066
3164
 
3067
- set current(selection) {
3068
- this.editor.update(() => {
3069
- this.#syncSelectedClasses();
3070
- });
3071
- }
3072
-
3073
3165
  get hasNodeSelection() {
3074
3166
  return this.editor.getEditorState().read(() => {
3075
3167
  const selection = $getSelection();
@@ -3398,7 +3490,7 @@ class Selection {
3398
3490
  this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),
3399
3491
 
3400
3492
  this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
3401
- this.current = $getSelection();
3493
+ this.#syncSelectedClasses();
3402
3494
  }, COMMAND_PRIORITY_LOW)
3403
3495
  );
3404
3496
  }
@@ -3596,6 +3688,7 @@ class Selection {
3596
3688
 
3597
3689
  #selectInLexical(node) {
3598
3690
  if ($isDecoratorNode(node)) {
3691
+ $addUpdateTag(HISTORY_MERGE_TAG);
3599
3692
  const selection = $createNodeSelectionWith(node);
3600
3693
  $setSelection(selection);
3601
3694
  return selection
@@ -3961,107 +4054,6 @@ class EditorConfiguration {
3961
4054
  }
3962
4055
  }
3963
4056
 
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
4057
  async function loadFileIntoImage(file, image) {
4066
4058
  return new Promise((resolve) => {
4067
4059
  const reader = new FileReader();
@@ -4097,10 +4089,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4097
4089
  }
4098
4090
 
4099
4091
  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;
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;
4104
4096
  this.uploadUrl = uploadUrl;
4105
4097
  this.blobUrlTemplate = blobUrlTemplate;
4106
4098
  this.progress = progress ?? null;
@@ -4157,6 +4149,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4157
4149
  ...super.exportJSON(),
4158
4150
  type: "action_text_attachment_upload",
4159
4151
  version: 1,
4152
+ fileName: this.fileName,
4153
+ contentType: this.contentType,
4160
4154
  uploadUrl: this.uploadUrl,
4161
4155
  blobUrlTemplate: this.blobUrlTemplate,
4162
4156
  progress: this.progress,
@@ -4181,14 +4175,14 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4181
4175
  }
4182
4176
 
4183
4177
  #getFileExtension() {
4184
- return this.file.name.split(".").pop().toLowerCase()
4178
+ return (this.fileName || "").split(".").pop().toLowerCase()
4185
4179
  }
4186
4180
 
4187
4181
  #createCaption() {
4188
4182
  const figcaption = createElement("figcaption", { className: "attachment__caption" });
4189
4183
 
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) });
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) });
4192
4186
  figcaption.appendChild(nameSpan);
4193
4187
  figcaption.appendChild(sizeSpan);
4194
4188
 
@@ -4202,11 +4196,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4202
4196
  #setDimensionsFromImage({ width, height }) {
4203
4197
  if (this.#hasDimensions) return
4204
4198
 
4205
- this.editor.update(() => {
4206
- const writable = this.getWritable();
4207
- writable.width = width;
4208
- writable.height = height;
4209
- }, { tag: this.#backgroundUpdateTags });
4199
+ this.patchAndRewriteHistory({ width, height });
4210
4200
  }
4211
4201
 
4212
4202
  get #hasDimensions() {
@@ -4233,8 +4223,8 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4233
4223
  } else {
4234
4224
  this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null });
4235
4225
  this.editor.update(() => {
4236
- this.showUploadedAttachment(blob);
4237
- }, { tag: this.#backgroundUpdateTags });
4226
+ this.$showUploadedAttachment(blob);
4227
+ });
4238
4228
  }
4239
4229
  });
4240
4230
  }
@@ -4249,7 +4239,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4249
4239
  directUploadWillStoreFileWithXHR: (request) => {
4250
4240
  if (shouldAuthenticateUploads) request.withCredentials = true;
4251
4241
 
4252
- const uploadProgressHandler = (event) => this.#handleUploadProgress(event);
4242
+ const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request);
4253
4243
  request.upload.addEventListener("progress", uploadProgressHandler);
4254
4244
  }
4255
4245
  }
@@ -4259,70 +4249,35 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
4259
4249
  this.#setProgress(1);
4260
4250
  }
4261
4251
 
4262
- #handleUploadProgress(event) {
4252
+ #handleUploadProgress(event, request) {
4263
4253
  const progress = Math.round(event.loaded / event.total * 100);
4264
- this.#setProgress(progress);
4265
- 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
+ }
4266
4260
  }
4267
4261
 
4268
4262
  #setProgress(progress) {
4269
- this.editor.update(() => {
4270
- this.getWritable().progress = progress;
4271
- }, { tag: this.#backgroundUpdateTags });
4263
+ this.patchAndRewriteHistory({ progress });
4272
4264
  }
4273
4265
 
4274
4266
  #handleUploadError(error) {
4275
4267
  console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`);
4276
- this.editor.update(() => {
4277
- this.getWritable().uploadError = true;
4278
- }, { tag: this.#backgroundUpdateTags });
4268
+
4269
+ this.patchAndRewriteHistory({ uploadError: true });
4279
4270
  }
4280
4271
 
4281
- showUploadedAttachment(blob) {
4272
+ $showUploadedAttachment(blob) {
4282
4273
  const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null;
4283
4274
 
4284
4275
  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
- }
4276
+ this.replaceAndRewriteHistory(replacementNode);
4291
4277
 
4292
4278
  return replacementNode.getKey()
4293
4279
  }
4294
4280
 
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
4281
  #toActionTextAttachmentNodeWith(blob, previewSrc) {
4327
4282
  const conversion = new AttachmentNodeConversion(this, blob, previewSrc);
4328
4283
  return conversion.toAttachmentNode()
@@ -4672,41 +4627,144 @@ class GalleryUploader extends Uploader {
4672
4627
  }
4673
4628
  }
4674
4629
 
4675
- class Contents {
4676
- constructor(editorElement) {
4677
- this.editorElement = editorElement;
4678
- 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
4679
4640
  }
4680
4641
 
4681
- dispose() {
4682
- this.editorElement = null;
4683
- this.editor = null;
4642
+ constructor(selection) {
4643
+ this.selection = selection;
4684
4644
  }
4645
+ }
4685
4646
 
4686
- get selection() { return this.editorElement.selection }
4687
-
4688
- insertHtml(html, { tag } = {}) {
4689
- this.insertDOM(parseHtml(html), { tag });
4647
+ class CodeNodeInserter extends NodeInserter {
4648
+ static handles(selection) {
4649
+ return $getNearestNodeOfType(selection.anchor?.getNode(), CodeNode)
4690
4650
  }
4691
4651
 
4692
- insertDOM(doc, { tag } = {}) {
4693
- this.#unwrapPlaceholderAnchors(doc);
4694
-
4695
- this.editor.update(() => {
4696
- if ($hasUpdateTag(PASTE_TAG)) this.#stripTableCellColorStyles(doc);
4652
+ insertNodes(nodes) {
4653
+ if (!this.selection.isCollapsed()) { this.selection.removeText(); }
4697
4654
 
4698
- const nodes = $generateNodesFromDOM(this.editor, doc);
4699
- if (!this.#insertUploadNodes(nodes)) {
4700
- this.insertAtCursor(...nodes);
4701
- }
4702
- }, { tag });
4703
- }
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();
4704
4659
 
4705
- insertAtCursor(...nodes) {
4706
- const selection = $getSelection() ?? $getRoot().selectEnd();
4707
- const inserter = NodeInserter.for(selection);
4660
+ const caret = $getChildCaretAtIndex(codeNode, insertionIndex + 1, "previous");
4708
4661
 
4709
- inserter.insertNodes(nodes);
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
+ }
4762
+
4763
+ insertAtCursor(...nodes) {
4764
+ const selection = $getSelection() ?? $getRoot().selectEnd();
4765
+ const inserter = NodeInserter.for(selection);
4766
+
4767
+ inserter.insertNodes(nodes);
4710
4768
  }
4711
4769
 
4712
4770
  insertAtCursorEnsuringLineBelow(node) {
@@ -4942,7 +5000,7 @@ class Contents {
4942
5000
  const node = $getNodeByKey(nodeKey);
4943
5001
  if (!(node instanceof ActionTextAttachmentUploadNode)) return
4944
5002
 
4945
- const replacementNodeKey = node.showUploadedAttachment(blob);
5003
+ const replacementNodeKey = node.$showUploadedAttachment(blob);
4946
5004
  if (replacementNodeKey) {
4947
5005
  nodeKey = replacementNodeKey;
4948
5006
  }
@@ -5281,113 +5339,6 @@ class Contents {
5281
5339
  }
5282
5340
  }
5283
5341
 
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
5342
  class Clipboard {
5392
5343
  constructor(editorElement) {
5393
5344
  this.editorElement = editorElement;
@@ -5694,6 +5645,107 @@ function unwrapSpans(element) {
5694
5645
  return element
5695
5646
  }
5696
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
+
5697
5749
  class ProvisionalParagraphExtension extends LexxyExtension {
5698
5750
  get lexicalExtension() {
5699
5751
  return defineExtension({
@@ -5715,6 +5767,8 @@ class ProvisionalParagraphExtension extends LexxyExtension {
5715
5767
  }
5716
5768
 
5717
5769
  function $insertRequiredProvisionalParagraphs(rootNode) {
5770
+ const nodeBeforeRootSelection = $nodeBeforeRootSelection(rootNode);
5771
+
5718
5772
  const firstNode = rootNode.getFirstChild();
5719
5773
  if (ProvisionalParagraphNode.neededBetween(null, firstNode)) {
5720
5774
  $insertFirst(rootNode, new ProvisionalParagraphNode);
@@ -5724,10 +5778,18 @@ function $insertRequiredProvisionalParagraphs(rootNode) {
5724
5778
  const nextNode = node.getNextSibling();
5725
5779
  if (ProvisionalParagraphNode.neededBetween(node, nextNode)) {
5726
5780
  node.insertAfter(new ProvisionalParagraphNode);
5781
+ if (node.is(nodeBeforeRootSelection)) node.selectNext();
5727
5782
  }
5728
5783
  }
5729
5784
  }
5730
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
+
5731
5793
  function $removeUnneededProvisionalParagraphs(rootNode) {
5732
5794
  for (const provisionalParagraph of $getAllProvisionalParagraphs(rootNode)) {
5733
5795
  provisionalParagraph.removeUnlessRequired();
@@ -5735,6 +5797,9 @@ function $removeUnneededProvisionalParagraphs(rootNode) {
5735
5797
  }
5736
5798
 
5737
5799
  function $markAllProvisionalParagraphsDirty() {
5800
+ // Selection-driven visibility updates must not become standalone undo steps.
5801
+ $addUpdateTag(HISTORY_MERGE_TAG);
5802
+
5738
5803
  for (const provisionalParagraph of $getAllProvisionalParagraphs()) {
5739
5804
  provisionalParagraph.markDirty();
5740
5805
  }
@@ -6615,54 +6680,58 @@ class LinkOpenerExtension extends LexxyExtension {
6615
6680
  get lexicalExtension() {
6616
6681
  return defineExtension({
6617
6682
  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
- }
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
+ )
6626
6690
  })
6627
6691
  }
6628
6692
 
6629
- #update(event) {
6693
+ #handleClick(event) {
6630
6694
  if (this.#isModified(event)) {
6631
- this.#enable();
6695
+ return $openLink(event.target)
6632
6696
  } else {
6633
- this.#disable();
6697
+ return false
6634
6698
  }
6635
6699
  }
6636
6700
 
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);
6701
+ #handleAuxClick(event) {
6702
+ if (event.button === 1) {
6703
+ this.editorElement.editor.read(() => $openLink(event.target));
6704
+ }
6642
6705
  }
6643
6706
 
6644
- #isModified(event) {
6645
- return IS_APPLE ? event.metaKey : event.ctrlKey
6707
+ #handleKey(event) {
6708
+ this.#updateOpenableAttribute(event);
6646
6709
  }
6647
6710
 
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
- }
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 });
6654
6715
  }
6655
6716
 
6656
- #disable() {
6657
- for (const anchor of this.#anchors) {
6658
- anchor.removeAttribute("contenteditable");
6659
- anchor.removeAttribute("target");
6660
- anchor.removeAttribute("rel");
6661
- }
6717
+ #updateOpenableAttribute(event) {
6718
+ this.editorElement.toggleAttribute("data-links-openable", this.#isModified(event));
6662
6719
  }
6663
6720
 
6664
- get #anchors() {
6665
- 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
6666
6735
  }
6667
6736
  }
6668
6737
 
@@ -6674,11 +6743,11 @@ class LexicalEditorElement extends HTMLElement {
6674
6743
  static observedAttributes = [ "connected", "required" ]
6675
6744
 
6676
6745
  #initialValue = ""
6677
- #initialValueLoaded = false
6678
6746
  #validationTextArea = document.createElement("textarea")
6679
6747
  #editorInitializedRafId = null
6680
6748
  #listeners = new ListenerBin()
6681
6749
  #disposables = []
6750
+ #historyState = { undo: false, redo: false }
6682
6751
 
6683
6752
  constructor() {
6684
6753
  super();
@@ -6770,6 +6839,7 @@ class LexicalEditorElement extends HTMLElement {
6770
6839
  HighlightExtension,
6771
6840
  TrixContentExtension,
6772
6841
  TablesExtension,
6842
+ RewritableHistoryExtension,
6773
6843
  AttachmentsExtension,
6774
6844
  FormatEscapeExtension,
6775
6845
  LinkOpenerExtension
@@ -6876,28 +6946,22 @@ class LexicalEditorElement extends HTMLElement {
6876
6946
  }
6877
6947
 
6878
6948
  set value(html) {
6879
- const wasEmpty = !this.#initialValueLoaded;
6880
-
6881
6949
  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();
6950
+ $getRoot()
6951
+ .clear()
6952
+ .selectEnd()
6953
+ .insertNodes(this.#parseHtmlIntoLexicalNodes(html));
6887
6954
 
6888
6955
  this.#toggleEmptyStatus();
6956
+ }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG });
6957
+ }
6889
6958
 
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
- });
6959
+ get canUndo() {
6960
+ return this.#historyState.undo
6961
+ }
6899
6962
 
6900
- this.#initialValueLoaded = true;
6963
+ get canRedo() {
6964
+ return this.#historyState.redo
6901
6965
  }
6902
6966
 
6903
6967
  #parseHtmlIntoLexicalNodes(html) {
@@ -6933,6 +6997,7 @@ class LexicalEditorElement extends HTMLElement {
6933
6997
  this.#registerComponents();
6934
6998
  this.#handleEnter();
6935
6999
  this.#registerFocusEvents();
7000
+ this.#registerHistoryEvents();
6936
7001
  this.#attachDebugHooks();
6937
7002
  this.#attachToolbar();
6938
7003
  this.#configureSanitizer();
@@ -7029,8 +7094,10 @@ class LexicalEditorElement extends HTMLElement {
7029
7094
  }
7030
7095
 
7031
7096
  #loadInitialValue() {
7032
- const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p></p>";
7033
- 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 });
7034
7101
  }
7035
7102
 
7036
7103
  #resetBeforeTurboCaches() {
@@ -7080,8 +7147,6 @@ class LexicalEditorElement extends HTMLElement {
7080
7147
  } else {
7081
7148
  registered.push(registerPlainText(this.editor));
7082
7149
  }
7083
- this.historyState = createEmptyHistoryState();
7084
- registered.push(registerHistory(this.editor, this.historyState, 20));
7085
7150
 
7086
7151
  this.#listeners.track(...registered);
7087
7152
  }
@@ -7164,6 +7229,12 @@ class LexicalEditorElement extends HTMLElement {
7164
7229
  }
7165
7230
  }
7166
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
+ }
7167
7238
 
7168
7239
  #attachDebugHooks() {
7169
7240
  return
@@ -7254,8 +7325,8 @@ class LexicalEditorElement extends HTMLElement {
7254
7325
  heading: { active: format.isInHeading, enabled: true },
7255
7326
  "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true },
7256
7327
  "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 }
7328
+ undo: { active: false, enabled: this.canUndo },
7329
+ redo: { active: false, enabled: this.canRedo }
7259
7330
  };
7260
7331
 
7261
7332
  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.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
  },