@37signals/lexxy 0.1.18-beta → 0.1.20-beta

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.
Files changed (2) hide show
  1. package/dist/lexxy.esm.js +159 -25
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -756,6 +756,10 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
756
756
  return new ActionTextAttachmentUploadNode({ ...node }, node.__key)
757
757
  }
758
758
 
759
+ static importJSON(serializedNode) {
760
+ return new ActionTextAttachmentUploadNode({ ...serializedNode })
761
+ }
762
+
759
763
  constructor({ file, uploadUrl, blobUrlTemplate, editor, progress }, key) {
760
764
  super({ contentType: file.type }, key);
761
765
  this.file = file;
@@ -795,6 +799,17 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
795
799
  return { element: img }
796
800
  }
797
801
 
802
+ exportJSON() {
803
+ return {
804
+ type: "action_text_attachment_upload",
805
+ version: 1,
806
+ progress: this.progress,
807
+ uploadUrl: this.uploadUrl,
808
+ blobUrlTemplate: this.blobUrlTemplate,
809
+ ...super.exportJSON()
810
+ }
811
+ }
812
+
798
813
  #createDOMForImage() {
799
814
  return createElement("img")
800
815
  }
@@ -1080,8 +1095,10 @@ class CommandDispatcher {
1080
1095
 
1081
1096
  dispatchInsertHorizontalDivider() {
1082
1097
  this.editor.update(() => {
1083
- this.contents.insertAtCursor(new HorizontalDividerNode());
1098
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
1084
1099
  });
1100
+
1101
+ this.editor.focus();
1085
1102
  }
1086
1103
 
1087
1104
  dispatchRotateHeadingFormat() {
@@ -1293,6 +1310,52 @@ class Selection {
1293
1310
  });
1294
1311
  }
1295
1312
 
1313
+ selectedNodeWithOffset() {
1314
+ const selection = $getSelection();
1315
+ if (!selection) return { node: null, offset: 0 }
1316
+
1317
+ if ($isRangeSelection(selection)) {
1318
+ return {
1319
+ node: selection.anchor.getNode(),
1320
+ offset: selection.anchor.offset
1321
+ }
1322
+ } else if ($isNodeSelection(selection)) {
1323
+ const [ node ] = selection.getNodes();
1324
+ return {
1325
+ node,
1326
+ offset: 0
1327
+ }
1328
+ }
1329
+
1330
+ return { node: null, offset: 0 }
1331
+ }
1332
+
1333
+ preservingSelection(fn) {
1334
+ let selectionState = null;
1335
+
1336
+ this.editor.getEditorState().read(() => {
1337
+ const selection = $getSelection();
1338
+ if (selection && $isRangeSelection(selection)) {
1339
+ selectionState = {
1340
+ anchor: { key: selection.anchor.key, offset: selection.anchor.offset },
1341
+ focus: { key: selection.focus.key, offset: selection.focus.offset }
1342
+ };
1343
+ }
1344
+ });
1345
+
1346
+ fn();
1347
+
1348
+ if (selectionState) {
1349
+ this.editor.update(() => {
1350
+ const selection = $getSelection();
1351
+ if (selection && $isRangeSelection(selection)) {
1352
+ selection.anchor.set(selectionState.anchor.key, selectionState.anchor.offset, "text");
1353
+ selection.focus.set(selectionState.focus.key, selectionState.focus.offset, "text");
1354
+ }
1355
+ });
1356
+ }
1357
+ }
1358
+
1296
1359
  get hasSelectedWordsInSingleLine() {
1297
1360
  const selection = $getSelection();
1298
1361
  if (!$isRangeSelection(selection)) return false
@@ -1661,22 +1724,24 @@ class Selection {
1661
1724
  const node = this.nodeAfterCursor;
1662
1725
  if (node instanceof DecoratorNode) {
1663
1726
  this.#selectInLexical(node);
1727
+ return true
1664
1728
  } else {
1665
1729
  this.#contents.deleteSelectedNodes();
1666
1730
  }
1667
1731
 
1668
- return true
1732
+ return false
1669
1733
  }
1670
1734
 
1671
1735
  #deletePreviousOrNext() {
1672
1736
  const node = this.nodeBeforeCursor;
1673
1737
  if (node instanceof DecoratorNode) {
1674
1738
  this.#selectInLexical(node);
1739
+ return true
1675
1740
  } else {
1676
1741
  this.#contents.deleteSelectedNodes();
1677
1742
  }
1678
1743
 
1679
- return true
1744
+ return false
1680
1745
  }
1681
1746
 
1682
1747
  #getValidSelectionRange() {
@@ -2248,6 +2313,11 @@ class Contents {
2248
2313
  });
2249
2314
  }
2250
2315
 
2316
+ insertAtCursorEnsuringLineBelow(node) {
2317
+ this.insertAtCursor(node);
2318
+ this.#insertLineBelowIfLastNode(node);
2319
+ }
2320
+
2251
2321
  insertNodeWrappingEachSelectedLine(newNodeFn) {
2252
2322
  this.editor.update(() => {
2253
2323
  const selection = $getSelection();
@@ -2532,6 +2602,17 @@ class Contents {
2532
2602
  return this.editorElement.selection
2533
2603
  }
2534
2604
 
2605
+ #insertLineBelowIfLastNode(node) {
2606
+ this.editor.update(() => {
2607
+ const nextSibling = node.getNextSibling();
2608
+ if (!nextSibling) {
2609
+ const newParagraph = $createParagraphNode();
2610
+ node.insertAfter(newParagraph);
2611
+ newParagraph.selectStart();
2612
+ }
2613
+ });
2614
+ }
2615
+
2535
2616
  #unwrap(node) {
2536
2617
  const children = node.getChildren();
2537
2618
 
@@ -2874,7 +2955,8 @@ class Contents {
2874
2955
  lastInsertedNode.insertAfter(textNodeAfter);
2875
2956
 
2876
2957
  this.#appendLineBreakIfNeeded(textNodeAfter.getParentOrThrow());
2877
- textNodeAfter.select(0, 0);
2958
+ const cursorOffset = textAfterCursor ? 0 : 1;
2959
+ textNodeAfter.select(cursorOffset, cursorOffset);
2878
2960
  }
2879
2961
 
2880
2962
  #insertReplacementNodes(startNode, replacementNodes) {
@@ -3664,6 +3746,14 @@ class LexicalPromptElement extends HTMLElement {
3664
3746
  return this.hasAttribute("supports-space-in-searches")
3665
3747
  }
3666
3748
 
3749
+ get open() {
3750
+ return this.popoverElement?.classList?.contains("lexxy-prompt-menu--visible")
3751
+ }
3752
+
3753
+ get closed() {
3754
+ return !this.open
3755
+ }
3756
+
3667
3757
  get #doesSpaceSelect() {
3668
3758
  return !this.supportsSpaceInSearches
3669
3759
  }
@@ -3684,20 +3774,14 @@ class LexicalPromptElement extends HTMLElement {
3684
3774
  #addTriggerListener() {
3685
3775
  const unregister = this.#editor.registerUpdateListener(() => {
3686
3776
  this.#editor.read(() => {
3687
- const selection = $getSelection();
3688
- if (!selection) return
3689
- let node;
3690
- if ($isRangeSelection(selection)) {
3691
- node = selection.anchor.getNode();
3692
- } else if ($isNodeSelection(selection)) {
3693
- [ node ] = selection.getNodes();
3694
- }
3777
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
3778
+ if (!node) return
3695
3779
 
3696
- if (node && $isTextNode(node)) {
3697
- const text = node.getTextContent().trim();
3698
- const lastChar = [ ...text ].pop();
3780
+ if ($isTextNode(node) && offset > 0) {
3781
+ const fullText = node.getTextContent();
3782
+ const charBeforeCursor = fullText[offset - 1];
3699
3783
 
3700
- if (lastChar === this.trigger) {
3784
+ if (charBeforeCursor === this.trigger) {
3701
3785
  unregister();
3702
3786
  this.#showPopover();
3703
3787
  }
@@ -3706,6 +3790,38 @@ class LexicalPromptElement extends HTMLElement {
3706
3790
  });
3707
3791
  }
3708
3792
 
3793
+ #addCursorPositionListener() {
3794
+ this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
3795
+ if (this.closed) return
3796
+
3797
+ this.#editor.read(() => {
3798
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
3799
+ if (!node) return
3800
+
3801
+ if ($isTextNode(node) && offset > 0) {
3802
+ const fullText = node.getTextContent();
3803
+ const textBeforeCursor = fullText.slice(0, offset);
3804
+ const lastTriggerIndex = textBeforeCursor.lastIndexOf(this.trigger);
3805
+
3806
+ // If trigger is not found, or cursor is at or before the trigger position, hide popover
3807
+ if (lastTriggerIndex === -1 || offset <= lastTriggerIndex) {
3808
+ this.#hidePopover();
3809
+ }
3810
+ } else {
3811
+ // Cursor is not in a text node or at offset 0, hide popover
3812
+ this.#hidePopover();
3813
+ }
3814
+ });
3815
+ });
3816
+ }
3817
+
3818
+ #removeCursorPositionListener() {
3819
+ if (this.cursorPositionListener) {
3820
+ this.cursorPositionListener();
3821
+ this.cursorPositionListener = null;
3822
+ }
3823
+ }
3824
+
3709
3825
  get #editor() {
3710
3826
  return this.#editorElement.editor
3711
3827
  }
@@ -3729,6 +3845,7 @@ class LexicalPromptElement extends HTMLElement {
3729
3845
  this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
3730
3846
 
3731
3847
  this.#registerKeyListeners();
3848
+ this.#addCursorPositionListener();
3732
3849
  }
3733
3850
 
3734
3851
  #registerKeyListeners() {
@@ -3739,6 +3856,22 @@ class LexicalPromptElement extends HTMLElement {
3739
3856
  if (this.#doesSpaceSelect) {
3740
3857
  this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
3741
3858
  }
3859
+
3860
+ // Register arrow keys with HIGH priority to prevent Lexical's selection handlers from running
3861
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#handleArrowUp.bind(this), COMMAND_PRIORITY_HIGH));
3862
+ this.keyListeners.push(this.#editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#handleArrowDown.bind(this), COMMAND_PRIORITY_HIGH));
3863
+ }
3864
+
3865
+ #handleArrowUp(event) {
3866
+ this.#moveSelectionUp();
3867
+ event.preventDefault();
3868
+ return true
3869
+ }
3870
+
3871
+ #handleArrowDown(event) {
3872
+ this.#moveSelectionDown();
3873
+ event.preventDefault();
3874
+ return true
3742
3875
  }
3743
3876
 
3744
3877
  #selectFirstOption() {
@@ -3756,8 +3889,14 @@ class LexicalPromptElement extends HTMLElement {
3756
3889
  #selectOption(listItem) {
3757
3890
  this.#clearSelection();
3758
3891
  listItem.toggleAttribute("aria-selected", true);
3892
+ listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
3759
3893
  listItem.focus();
3760
- this.#editorElement.focus();
3894
+
3895
+ // Preserve selection to prevent cursor jump
3896
+ this.#selection.preservingSelection(() => {
3897
+ this.#editorElement.focus();
3898
+ });
3899
+
3761
3900
  this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
3762
3901
  this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
3763
3902
  this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
@@ -3806,6 +3945,7 @@ class LexicalPromptElement extends HTMLElement {
3806
3945
  this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
3807
3946
 
3808
3947
  this.#unregisterKeyListeners();
3948
+ this.#removeCursorPositionListener();
3809
3949
 
3810
3950
  await nextFrame();
3811
3951
  this.#addTriggerListener();
@@ -3824,6 +3964,7 @@ class LexicalPromptElement extends HTMLElement {
3824
3964
 
3825
3965
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
3826
3966
  await this.#showFilteredOptions();
3967
+ await nextFrame();
3827
3968
  this.#positionPopover();
3828
3969
  } else {
3829
3970
  this.#hidePopover();
@@ -3864,15 +4005,8 @@ class LexicalPromptElement extends HTMLElement {
3864
4005
  this.#hidePopover();
3865
4006
  this.#editorElement.focus();
3866
4007
  event.stopPropagation();
3867
- } else if (event.key === "ArrowDown") {
3868
- this.#moveSelectionDown();
3869
- event.preventDefault();
3870
- event.stopPropagation();
3871
- } else if (event.key === "ArrowUp") {
3872
- this.#moveSelectionUp();
3873
- event.preventDefault();
3874
- event.stopPropagation();
3875
4008
  }
4009
+ // Arrow keys are now handled via Lexical commands with HIGH priority
3876
4010
  }
3877
4011
 
3878
4012
  #moveSelectionDown() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.18-beta",
3
+ "version": "0.1.20-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",