@37signals/lexxy 0.1.19-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 +140 -23
  2. package/package.json +1 -1
package/dist/lexxy.esm.js CHANGED
@@ -1095,8 +1095,10 @@ class CommandDispatcher {
1095
1095
 
1096
1096
  dispatchInsertHorizontalDivider() {
1097
1097
  this.editor.update(() => {
1098
- this.contents.insertAtCursor(new HorizontalDividerNode());
1098
+ this.contents.insertAtCursorEnsuringLineBelow(new HorizontalDividerNode());
1099
1099
  });
1100
+
1101
+ this.editor.focus();
1100
1102
  }
1101
1103
 
1102
1104
  dispatchRotateHeadingFormat() {
@@ -1308,6 +1310,52 @@ class Selection {
1308
1310
  });
1309
1311
  }
1310
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
+
1311
1359
  get hasSelectedWordsInSingleLine() {
1312
1360
  const selection = $getSelection();
1313
1361
  if (!$isRangeSelection(selection)) return false
@@ -2265,6 +2313,11 @@ class Contents {
2265
2313
  });
2266
2314
  }
2267
2315
 
2316
+ insertAtCursorEnsuringLineBelow(node) {
2317
+ this.insertAtCursor(node);
2318
+ this.#insertLineBelowIfLastNode(node);
2319
+ }
2320
+
2268
2321
  insertNodeWrappingEachSelectedLine(newNodeFn) {
2269
2322
  this.editor.update(() => {
2270
2323
  const selection = $getSelection();
@@ -2549,6 +2602,17 @@ class Contents {
2549
2602
  return this.editorElement.selection
2550
2603
  }
2551
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
+
2552
2616
  #unwrap(node) {
2553
2617
  const children = node.getChildren();
2554
2618
 
@@ -2891,7 +2955,8 @@ class Contents {
2891
2955
  lastInsertedNode.insertAfter(textNodeAfter);
2892
2956
 
2893
2957
  this.#appendLineBreakIfNeeded(textNodeAfter.getParentOrThrow());
2894
- textNodeAfter.select(0, 0);
2958
+ const cursorOffset = textAfterCursor ? 0 : 1;
2959
+ textNodeAfter.select(cursorOffset, cursorOffset);
2895
2960
  }
2896
2961
 
2897
2962
  #insertReplacementNodes(startNode, replacementNodes) {
@@ -3681,6 +3746,14 @@ class LexicalPromptElement extends HTMLElement {
3681
3746
  return this.hasAttribute("supports-space-in-searches")
3682
3747
  }
3683
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
+
3684
3757
  get #doesSpaceSelect() {
3685
3758
  return !this.supportsSpaceInSearches
3686
3759
  }
@@ -3701,20 +3774,14 @@ class LexicalPromptElement extends HTMLElement {
3701
3774
  #addTriggerListener() {
3702
3775
  const unregister = this.#editor.registerUpdateListener(() => {
3703
3776
  this.#editor.read(() => {
3704
- const selection = $getSelection();
3705
- if (!selection) return
3706
- let node;
3707
- if ($isRangeSelection(selection)) {
3708
- node = selection.anchor.getNode();
3709
- } else if ($isNodeSelection(selection)) {
3710
- [ node ] = selection.getNodes();
3711
- }
3777
+ const { node, offset } = this.#selection.selectedNodeWithOffset();
3778
+ if (!node) return
3712
3779
 
3713
- if (node && $isTextNode(node)) {
3714
- const text = node.getTextContent().trim();
3715
- const lastChar = [ ...text ].pop();
3780
+ if ($isTextNode(node) && offset > 0) {
3781
+ const fullText = node.getTextContent();
3782
+ const charBeforeCursor = fullText[offset - 1];
3716
3783
 
3717
- if (lastChar === this.trigger) {
3784
+ if (charBeforeCursor === this.trigger) {
3718
3785
  unregister();
3719
3786
  this.#showPopover();
3720
3787
  }
@@ -3723,6 +3790,38 @@ class LexicalPromptElement extends HTMLElement {
3723
3790
  });
3724
3791
  }
3725
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
+
3726
3825
  get #editor() {
3727
3826
  return this.#editorElement.editor
3728
3827
  }
@@ -3746,6 +3845,7 @@ class LexicalPromptElement extends HTMLElement {
3746
3845
  this.#editorElement.addEventListener("lexxy:change", this.#filterOptions);
3747
3846
 
3748
3847
  this.#registerKeyListeners();
3848
+ this.#addCursorPositionListener();
3749
3849
  }
3750
3850
 
3751
3851
  #registerKeyListeners() {
@@ -3756,6 +3856,22 @@ class LexicalPromptElement extends HTMLElement {
3756
3856
  if (this.#doesSpaceSelect) {
3757
3857
  this.keyListeners.push(this.#editor.registerCommand(KEY_SPACE_COMMAND, this.#handleSelectedOption.bind(this), COMMAND_PRIORITY_HIGH));
3758
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
3759
3875
  }
3760
3876
 
3761
3877
  #selectFirstOption() {
@@ -3773,8 +3889,14 @@ class LexicalPromptElement extends HTMLElement {
3773
3889
  #selectOption(listItem) {
3774
3890
  this.#clearSelection();
3775
3891
  listItem.toggleAttribute("aria-selected", true);
3892
+ listItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
3776
3893
  listItem.focus();
3777
- this.#editorElement.focus();
3894
+
3895
+ // Preserve selection to prevent cursor jump
3896
+ this.#selection.preservingSelection(() => {
3897
+ this.#editorElement.focus();
3898
+ });
3899
+
3778
3900
  this.#editorContentElement.setAttribute("aria-controls", this.popoverElement.id);
3779
3901
  this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id);
3780
3902
  this.#editorContentElement.setAttribute("aria-haspopup", "listbox");
@@ -3823,6 +3945,7 @@ class LexicalPromptElement extends HTMLElement {
3823
3945
  this.#editorElement.removeEventListener("keydown", this.#handleKeydownOnPopover);
3824
3946
 
3825
3947
  this.#unregisterKeyListeners();
3948
+ this.#removeCursorPositionListener();
3826
3949
 
3827
3950
  await nextFrame();
3828
3951
  this.#addTriggerListener();
@@ -3841,6 +3964,7 @@ class LexicalPromptElement extends HTMLElement {
3841
3964
 
3842
3965
  if (this.#editorContents.containsTextBackUntil(this.trigger)) {
3843
3966
  await this.#showFilteredOptions();
3967
+ await nextFrame();
3844
3968
  this.#positionPopover();
3845
3969
  } else {
3846
3970
  this.#hidePopover();
@@ -3881,15 +4005,8 @@ class LexicalPromptElement extends HTMLElement {
3881
4005
  this.#hidePopover();
3882
4006
  this.#editorElement.focus();
3883
4007
  event.stopPropagation();
3884
- } else if (event.key === "ArrowDown") {
3885
- this.#moveSelectionDown();
3886
- event.preventDefault();
3887
- event.stopPropagation();
3888
- } else if (event.key === "ArrowUp") {
3889
- this.#moveSelectionUp();
3890
- event.preventDefault();
3891
- event.stopPropagation();
3892
4008
  }
4009
+ // Arrow keys are now handled via Lexical commands with HIGH priority
3893
4010
  }
3894
4011
 
3895
4012
  #moveSelectionDown() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.1.19-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",