@peers-app/peers-ui 0.15.5 → 0.16.0

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.
@@ -5,6 +5,7 @@ import {
5
5
  $createRangeSelection,
6
6
  $getNodeByKey,
7
7
  $getSelection,
8
+ $isElementNode,
8
9
  $isRangeSelection,
9
10
  $isRootOrShadowRoot,
10
11
  $setSelection,
@@ -15,10 +16,11 @@ import {
15
16
  import { useEffect } from "react";
16
17
 
17
18
  /**
18
- * Returns the node to reorder for Alt/Option + arrow "move line":
19
- * a list item (among siblings in its list), or else the top-level block under the root.
19
+ * Walks up from `node` to find the reorderable block for keyboard shortcuts that
20
+ * operate on "lines" (blocks). Returns a list item when inside a list, or the
21
+ * top-level block directly under the root.
20
22
  */
21
- function $getMovableBlock(node: LexicalNode): LexicalNode | null {
23
+ export function $getMovableBlock(node: LexicalNode): LexicalNode | null {
22
24
  let n: LexicalNode | null = node;
23
25
  while (n !== null) {
24
26
  const parent: LexicalNode | null = n.getParent();
@@ -37,7 +39,26 @@ function $getMovableBlock(node: LexicalNode): LexicalNode | null {
37
39
  }
38
40
 
39
41
  /**
40
- * Registers Alt/Option + ArrowUp/ArrowDown to swap the caret's block (or list item) with an adjacent sibling.
42
+ * Returns true when `node` sits at the absolute start of `block` i.e. it is
43
+ * the block itself or lies along the first-child path from block down to node.
44
+ * Used to detect a selection endpoint that bled into the next block at offset 0.
45
+ */
46
+ function $isFirstPositionInBlock(node: LexicalNode, block: LexicalNode): boolean {
47
+ let n: LexicalNode | null = node;
48
+ while (n !== null && n !== block) {
49
+ if (n.getPreviousSibling() !== null) {
50
+ return false;
51
+ }
52
+ n = n.getParent();
53
+ }
54
+ return n === block;
55
+ }
56
+
57
+ /**
58
+ * Registers Alt/Option + ArrowUp/ArrowDown to move the selected block(s) — or the
59
+ * block at the caret — past an adjacent sibling. Supports both collapsed cursors and
60
+ * multi-block selections: the single neighbor block is relocated to the other side of
61
+ * the selection range, which effectively shifts the selected blocks up or down by one.
41
62
  * @param props.mentionsOpen — when true, the plugin defers so mention typeahead keeps keyboard focus.
42
63
  */
43
64
  export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<boolean> }) {
@@ -64,34 +85,83 @@ export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<bo
64
85
  // built-in arrow handler + browser would move the caret on top of our swap.
65
86
 
66
87
  const selection = $getSelection();
67
- if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
88
+ if (!$isRangeSelection(selection)) {
89
+ return false;
90
+ }
91
+
92
+ const anchorBlock = $getMovableBlock(selection.anchor.getNode());
93
+ const focusBlock = $getMovableBlock(selection.focus.getNode());
94
+ if (anchorBlock === null || focusBlock === null) {
68
95
  return false;
69
96
  }
70
- const anchorNode = selection.anchor.getNode();
71
- const block = $getMovableBlock(anchorNode);
72
- if (block === null) {
97
+
98
+ // Both endpoints must share the same parent (same nesting level).
99
+ if (anchorBlock.getParent()?.getKey() !== focusBlock.getParent()?.getKey()) {
73
100
  return false;
74
101
  }
75
102
 
76
- const anchorKey = selection.anchor.key;
77
- const anchorOffset = selection.anchor.offset;
78
- const anchorType = selection.anchor.type;
79
- const focusKey = selection.focus.key;
80
- const focusOffset = selection.focus.offset;
81
- const focusType = selection.focus.type;
103
+ const anchorIdx = anchorBlock.getIndexWithinParent();
104
+ const focusIdx = focusBlock.getIndexWithinParent();
105
+ const firstBlock = anchorIdx <= focusIdx ? anchorBlock : focusBlock;
106
+ let lastBlock = anchorIdx <= focusIdx ? focusBlock : anchorBlock;
107
+
108
+ let anchorKey = selection.anchor.key;
109
+ let anchorOffset = selection.anchor.offset;
110
+ let anchorType = selection.anchor.type;
111
+ let focusKey = selection.focus.key;
112
+ let focusOffset = selection.focus.offset;
113
+ let focusType = selection.focus.type;
114
+
115
+ // When a selection extends to offset 0 of the next block (common when
116
+ // selecting to "end of line"), that trailing block has no selected
117
+ // content — exclude it so it isn't dragged along. Also clamp the
118
+ // trailing selection endpoint to the end of the new lastBlock so the
119
+ // restored highlight doesn't overextend into the excluded block.
120
+ if (lastBlock !== firstBlock) {
121
+ const lastPoint = anchorIdx > focusIdx ? selection.anchor : selection.focus;
122
+ if (lastPoint.offset === 0 && $isFirstPositionInBlock(lastPoint.getNode(), lastBlock)) {
123
+ const stepped = lastBlock.getPreviousSibling();
124
+ if (stepped === null) {
125
+ return false;
126
+ }
127
+ lastBlock = stepped;
128
+
129
+ const lastDesc = $isElementNode(lastBlock) ? lastBlock.getLastDescendant() : null;
130
+ const clampKey = lastDesc ? lastDesc.getKey() : lastBlock.getKey();
131
+ const clampOffset = lastDesc
132
+ ? lastDesc.getTextContentSize()
133
+ : $isElementNode(lastBlock)
134
+ ? lastBlock.getChildrenSize()
135
+ : lastBlock.getTextContentSize();
136
+ const clampType: "text" | "element" = lastDesc
137
+ ? "text"
138
+ : $isElementNode(lastBlock)
139
+ ? "element"
140
+ : "text";
141
+ if (anchorIdx > focusIdx) {
142
+ anchorKey = clampKey;
143
+ anchorOffset = clampOffset;
144
+ anchorType = clampType;
145
+ } else {
146
+ focusKey = clampKey;
147
+ focusOffset = clampOffset;
148
+ focusType = clampType;
149
+ }
150
+ }
151
+ }
82
152
 
83
153
  if (code === "ArrowUp") {
84
- const prev = block.getPreviousSibling();
154
+ const prev = firstBlock.getPreviousSibling();
85
155
  if (prev === null) {
86
156
  return false;
87
157
  }
88
- prev.insertBefore(block, false);
158
+ lastBlock.insertAfter(prev, false);
89
159
  } else {
90
- const next = block.getNextSibling();
160
+ const next = lastBlock.getNextSibling();
91
161
  if (next === null) {
92
162
  return false;
93
163
  }
94
- next.insertAfter(block, false);
164
+ firstBlock.insertBefore(next, false);
95
165
  }
96
166
 
97
167
  if ($getNodeByKey(anchorKey) !== null) {
@@ -102,7 +172,7 @@ export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<bo
102
172
  restored.focus.set(focusKey, focusOffset, focusType);
103
173
  $setSelection(restored);
104
174
  } else {
105
- block.selectEnd();
175
+ lastBlock.selectEnd();
106
176
  }
107
177
 
108
178
  event.preventDefault();
@@ -0,0 +1,95 @@
1
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2
+ import {
3
+ $createRangeSelection,
4
+ $getNearestNodeFromDOMNode,
5
+ $getSelection,
6
+ $isRangeSelection,
7
+ $isTextNode,
8
+ $setSelection,
9
+ COMMAND_PRIORITY_HIGH,
10
+ KEY_DOWN_COMMAND,
11
+ } from "lexical";
12
+ import { useEffect } from "react";
13
+
14
+ /**
15
+ * Intercepts Cmd+Shift+ArrowLeft/Right (Mac line-boundary selection) and explicitly
16
+ * sets anchor/focus so that Lexical's reconciliation cannot flip the selection direction.
17
+ * Uses the browser's native `Selection.modify` with `'lineboundary'` granularity to
18
+ * find the correct **visual** line boundary (respecting text wrapping), then converts
19
+ * the result back to Lexical coordinates.
20
+ */
21
+ export function SelectToLineBoundaryPlugin() {
22
+ const [editor] = useLexicalComposerContext();
23
+
24
+ useEffect(() => {
25
+ return editor.registerCommand(
26
+ KEY_DOWN_COMMAND,
27
+ (event: KeyboardEvent) => {
28
+ if (!event.shiftKey || !event.metaKey || event.altKey || event.ctrlKey) {
29
+ return false;
30
+ }
31
+ const code = event.code;
32
+ if (code !== "ArrowLeft" && code !== "ArrowRight") {
33
+ return false;
34
+ }
35
+
36
+ const selection = $getSelection();
37
+ if (!$isRangeSelection(selection)) {
38
+ return false;
39
+ }
40
+
41
+ const savedAnchorKey = selection.anchor.key;
42
+ const savedAnchorOffset = selection.anchor.offset;
43
+ const savedAnchorType = selection.anchor.type;
44
+
45
+ const focusElement = editor.getElementByKey(selection.focus.key);
46
+ if (!focusElement) {
47
+ return false;
48
+ }
49
+ const focusDomNode =
50
+ selection.focus.type === "text" ? focusElement.firstChild : focusElement;
51
+ if (!focusDomNode) {
52
+ return false;
53
+ }
54
+
55
+ const domSelection = window.getSelection();
56
+ if (!domSelection) {
57
+ return false;
58
+ }
59
+
60
+ domSelection.collapse(focusDomNode, selection.focus.offset);
61
+ domSelection.modify("move", code === "ArrowLeft" ? "left" : "right", "lineboundary");
62
+
63
+ const boundaryDomNode = domSelection.anchorNode;
64
+ const boundaryDomOffset = domSelection.anchorOffset;
65
+ if (!boundaryDomNode) {
66
+ return false;
67
+ }
68
+
69
+ const boundaryLexicalNode = $getNearestNodeFromDOMNode(boundaryDomNode);
70
+ if (!boundaryLexicalNode) {
71
+ return false;
72
+ }
73
+
74
+ const newFocusKey = boundaryLexicalNode.getKey();
75
+ const newFocusOffset = boundaryDomOffset;
76
+ const newFocusType: "text" | "element" = $isTextNode(boundaryLexicalNode)
77
+ ? "text"
78
+ : "element";
79
+
80
+ const restored = $createRangeSelection();
81
+ restored.format = selection.format;
82
+ restored.style = selection.style;
83
+ restored.anchor.set(savedAnchorKey, savedAnchorOffset, savedAnchorType);
84
+ restored.focus.set(newFocusKey, newFocusOffset, newFocusType);
85
+ $setSelection(restored);
86
+
87
+ event.preventDefault();
88
+ return true;
89
+ },
90
+ COMMAND_PRIORITY_HIGH,
91
+ );
92
+ }, [editor]);
93
+
94
+ return null;
95
+ }