@peers-app/peers-ui 0.15.4 → 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.
@@ -0,0 +1,186 @@
1
+ import { $isListItemNode, $isListNode } from "@lexical/list";
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3
+ import type { Observable } from "@peers-app/peers-sdk";
4
+ import {
5
+ $createRangeSelection,
6
+ $getNodeByKey,
7
+ $getSelection,
8
+ $isElementNode,
9
+ $isRangeSelection,
10
+ $isRootOrShadowRoot,
11
+ $setSelection,
12
+ COMMAND_PRIORITY_HIGH,
13
+ KEY_DOWN_COMMAND,
14
+ type LexicalNode,
15
+ } from "lexical";
16
+ import { useEffect } from "react";
17
+
18
+ /**
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.
22
+ */
23
+ export function $getMovableBlock(node: LexicalNode): LexicalNode | null {
24
+ let n: LexicalNode | null = node;
25
+ while (n !== null) {
26
+ const parent: LexicalNode | null = n.getParent();
27
+ if (parent === null) {
28
+ return null;
29
+ }
30
+ if ($isListNode(parent) && $isListItemNode(n)) {
31
+ return n;
32
+ }
33
+ if ($isRootOrShadowRoot(parent)) {
34
+ return n;
35
+ }
36
+ n = parent;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
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.
62
+ * @param props.mentionsOpen — when true, the plugin defers so mention typeahead keeps keyboard focus.
63
+ */
64
+ export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<boolean> }) {
65
+ const [editor] = useLexicalComposerContext();
66
+
67
+ useEffect(() => {
68
+ return editor.registerCommand(
69
+ KEY_DOWN_COMMAND,
70
+ (event: KeyboardEvent) => {
71
+ if (props.mentionsOpen()) {
72
+ return false;
73
+ }
74
+ if (!event.altKey || event.metaKey || event.ctrlKey) {
75
+ return false;
76
+ }
77
+ const code = event.code;
78
+ if (code !== "ArrowUp" && code !== "ArrowDown") {
79
+ return false;
80
+ }
81
+
82
+ // KEY_DOWN_COMMAND runs inside Lexical's updateEditorSync; do not call
83
+ // editor.update() here — it would queue and run after this handler returns,
84
+ // so handled would stay false, preventDefault would never run, and the
85
+ // built-in arrow handler + browser would move the caret on top of our swap.
86
+
87
+ const selection = $getSelection();
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) {
95
+ return false;
96
+ }
97
+
98
+ // Both endpoints must share the same parent (same nesting level).
99
+ if (anchorBlock.getParent()?.getKey() !== focusBlock.getParent()?.getKey()) {
100
+ return false;
101
+ }
102
+
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
+ }
152
+
153
+ if (code === "ArrowUp") {
154
+ const prev = firstBlock.getPreviousSibling();
155
+ if (prev === null) {
156
+ return false;
157
+ }
158
+ lastBlock.insertAfter(prev, false);
159
+ } else {
160
+ const next = lastBlock.getNextSibling();
161
+ if (next === null) {
162
+ return false;
163
+ }
164
+ firstBlock.insertBefore(next, false);
165
+ }
166
+
167
+ if ($getNodeByKey(anchorKey) !== null) {
168
+ const restored = $createRangeSelection();
169
+ restored.format = selection.format;
170
+ restored.style = selection.style;
171
+ restored.anchor.set(anchorKey, anchorOffset, anchorType);
172
+ restored.focus.set(focusKey, focusOffset, focusType);
173
+ $setSelection(restored);
174
+ } else {
175
+ lastBlock.selectEnd();
176
+ }
177
+
178
+ event.preventDefault();
179
+ return true;
180
+ },
181
+ COMMAND_PRIORITY_HIGH,
182
+ );
183
+ }, [editor]);
184
+
185
+ return null;
186
+ }
@@ -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
+ }