@octanejs/lexical 0.1.2
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/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +104 -0
- package/src/LexicalAutoEmbedPlugin.tsrx +144 -0
- package/src/LexicalAutoFocusPlugin.tsrx +25 -0
- package/src/LexicalAutoLinkPlugin.tsrx +32 -0
- package/src/LexicalBlockWithAlignableContents.tsrx +85 -0
- package/src/LexicalCharacterLimitPlugin.tsrx +66 -0
- package/src/LexicalCheckListPlugin.tsrx +16 -0
- package/src/LexicalClearEditorPlugin.tsrx +12 -0
- package/src/LexicalClickableLinkPlugin.tsrx +17 -0
- package/src/LexicalCollaborationContext.ts +67 -0
- package/src/LexicalComposer.tsrx +100 -0
- package/src/LexicalComposerContext.ts +49 -0
- package/src/LexicalContentEditable.tsrx +48 -0
- package/src/LexicalDecoratorBlockNode.ts +78 -0
- package/src/LexicalDraggableBlockPlugin.tsrx +499 -0
- package/src/LexicalEditorRefPlugin.tsrx +19 -0
- package/src/LexicalErrorBoundary.tsrx +23 -0
- package/src/LexicalHashtagPlugin.tsrx +17 -0
- package/src/LexicalHistoryPlugin.tsrx +23 -0
- package/src/LexicalHorizontalRuleNode.tsrx +99 -0
- package/src/LexicalHorizontalRulePlugin.tsrx +34 -0
- package/src/LexicalLinkPlugin.tsrx +27 -0
- package/src/LexicalListPlugin.tsrx +37 -0
- package/src/LexicalMarkdownShortcutPlugin.tsrx +39 -0
- package/src/LexicalNestedComposer.tsrx +124 -0
- package/src/LexicalNodeContextMenuPlugin.tsrx +252 -0
- package/src/LexicalNodeEventPlugin.tsrx +48 -0
- package/src/LexicalNodeMenuPlugin.tsrx +84 -0
- package/src/LexicalOnChangePlugin.tsrx +36 -0
- package/src/LexicalPlainTextPlugin.tsrx +33 -0
- package/src/LexicalRichTextPlugin.tsrx +35 -0
- package/src/LexicalSelectionAlwaysOnDisplay.tsrx +11 -0
- package/src/LexicalTabIndentationPlugin.tsrx +21 -0
- package/src/LexicalTableOfContentsPlugin.tsrx +213 -0
- package/src/LexicalTablePlugin.tsrx +67 -0
- package/src/LexicalTypeaheadMenuPlugin.tsrx +141 -0
- package/src/autoEmbedShared.ts +43 -0
- package/src/index.ts +99 -0
- package/src/shared/LexicalContentEditableElement.tsrx +101 -0
- package/src/shared/LexicalDecorators.tsrx +88 -0
- package/src/shared/LexicalMenu.tsrx +275 -0
- package/src/shared/internal.ts +32 -0
- package/src/shared/menuShared.ts +152 -0
- package/src/shared/point.ts +46 -0
- package/src/shared/rect.ts +136 -0
- package/src/shared/useCanShowPlaceholder.ts +38 -0
- package/src/shared/useCharacterLimit.ts +288 -0
- package/src/shared/useDynamicPositioning.ts +72 -0
- package/src/shared/useMenuAnchorRef.ts +131 -0
- package/src/shared/usePlainTextSetup.ts +15 -0
- package/src/shared/useRichTextSetup.ts +16 -0
- package/src/tsrx-modules.d.ts +4 -0
- package/src/typeaheadShared.ts +132 -0
- package/src/types.ts +29 -0
- package/src/useLexicalEditable.ts +22 -0
- package/src/useLexicalIsTextContentEmpty.ts +35 -0
- package/src/useLexicalNodeSelection.ts +91 -0
- package/src/useLexicalSlotRef.ts +26 -0
- package/src/useLexicalSubscription.ts +55 -0
- package/src/useLexicalTextEntity.ts +24 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Ported verbatim from @lexical/react/src/shared/rect.ts (framework-agnostic).
|
|
2
|
+
import { isPoint, Point } from './point';
|
|
3
|
+
|
|
4
|
+
type ContainsPointReturn = {
|
|
5
|
+
result: boolean;
|
|
6
|
+
reason: {
|
|
7
|
+
isOnTopSide: boolean;
|
|
8
|
+
isOnBottomSide: boolean;
|
|
9
|
+
isOnLeftSide: boolean;
|
|
10
|
+
isOnRightSide: boolean;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class Rectangle {
|
|
15
|
+
private readonly _left: number;
|
|
16
|
+
private readonly _top: number;
|
|
17
|
+
private readonly _right: number;
|
|
18
|
+
private readonly _bottom: number;
|
|
19
|
+
|
|
20
|
+
constructor(left: number, top: number, right: number, bottom: number) {
|
|
21
|
+
const [physicTop, physicBottom] = top <= bottom ? [top, bottom] : [bottom, top];
|
|
22
|
+
|
|
23
|
+
const [physicLeft, physicRight] = left <= right ? [left, right] : [right, left];
|
|
24
|
+
|
|
25
|
+
this._top = physicTop;
|
|
26
|
+
this._right = physicRight;
|
|
27
|
+
this._left = physicLeft;
|
|
28
|
+
this._bottom = physicBottom;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get top(): number {
|
|
32
|
+
return this._top;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get right(): number {
|
|
36
|
+
return this._right;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get bottom(): number {
|
|
40
|
+
return this._bottom;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get left(): number {
|
|
44
|
+
return this._left;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get width(): number {
|
|
48
|
+
return Math.abs(this._left - this._right);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get height(): number {
|
|
52
|
+
return Math.abs(this._bottom - this._top);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public equals({ top, left, bottom, right }: Rectangle): boolean {
|
|
56
|
+
return (
|
|
57
|
+
top === this._top && bottom === this._bottom && left === this._left && right === this._right
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public contains({ x, y }: Point): ContainsPointReturn;
|
|
62
|
+
public contains({ top, left, bottom, right }: Rectangle): boolean;
|
|
63
|
+
public contains(target: Point | Rectangle): boolean | ContainsPointReturn {
|
|
64
|
+
if (isPoint(target)) {
|
|
65
|
+
const { x, y } = target;
|
|
66
|
+
|
|
67
|
+
const isOnTopSide = y < this._top;
|
|
68
|
+
const isOnBottomSide = y > this._bottom;
|
|
69
|
+
const isOnLeftSide = x < this._left;
|
|
70
|
+
const isOnRightSide = x > this._right;
|
|
71
|
+
|
|
72
|
+
const result = !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
reason: {
|
|
76
|
+
isOnBottomSide,
|
|
77
|
+
isOnLeftSide,
|
|
78
|
+
isOnRightSide,
|
|
79
|
+
isOnTopSide,
|
|
80
|
+
},
|
|
81
|
+
result,
|
|
82
|
+
};
|
|
83
|
+
} else {
|
|
84
|
+
const { top, left, bottom, right } = target;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
top >= this._top &&
|
|
88
|
+
top <= this._bottom &&
|
|
89
|
+
bottom >= this._top &&
|
|
90
|
+
bottom <= this._bottom &&
|
|
91
|
+
left >= this._left &&
|
|
92
|
+
left <= this._right &&
|
|
93
|
+
right >= this._left &&
|
|
94
|
+
right <= this._right
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public intersectsWith(rect: Rectangle): boolean {
|
|
100
|
+
const { left: x1, top: y1, width: w1, height: h1 } = rect;
|
|
101
|
+
const { left: x2, top: y2, width: w2, height: h2 } = this;
|
|
102
|
+
const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
|
|
103
|
+
const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
|
|
104
|
+
const minX = x1 <= x2 ? x1 : x2;
|
|
105
|
+
const minY = y1 <= y2 ? y1 : y2;
|
|
106
|
+
return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public generateNewRect({
|
|
110
|
+
left = this.left,
|
|
111
|
+
top = this.top,
|
|
112
|
+
right = this.right,
|
|
113
|
+
bottom = this.bottom,
|
|
114
|
+
}): Rectangle {
|
|
115
|
+
return new Rectangle(left, top, right, bottom);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
static fromLTRB(left: number, top: number, right: number, bottom: number): Rectangle {
|
|
119
|
+
return new Rectangle(left, top, right, bottom);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static fromLWTH(left: number, width: number, top: number, height: number): Rectangle {
|
|
123
|
+
return new Rectangle(left, top, left + width, top + height);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static fromPoints(startPoint: Point, endPoint: Point): Rectangle {
|
|
127
|
+
const { y: top, x: left } = startPoint;
|
|
128
|
+
const { y: bottom, x: right } = endPoint;
|
|
129
|
+
return Rectangle.fromLTRB(left, top, right, bottom);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static fromDOM(dom: HTMLElement): Rectangle {
|
|
133
|
+
const { top, width, left, height } = dom.getBoundingClientRect();
|
|
134
|
+
return Rectangle.fromLWTH(left, width, top, height);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LexicalEditor } from 'lexical';
|
|
2
|
+
|
|
3
|
+
import { $canShowPlaceholderCurry } from '@lexical/text';
|
|
4
|
+
import { mergeRegister } from 'lexical';
|
|
5
|
+
import { useState, useLayoutEffect } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { subSlot } from './internal';
|
|
8
|
+
|
|
9
|
+
// Ported from @lexical/react/src/shared/useCanShowPlaceholder.ts. Plain `.ts` hook:
|
|
10
|
+
// the caller injects `slot`; each base hook gets its own sub-slot.
|
|
11
|
+
|
|
12
|
+
function canShowPlaceholderFromCurrentEditorState(editor: LexicalEditor): boolean {
|
|
13
|
+
return editor.read('latest', $canShowPlaceholderCurry(editor.isComposing()));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useCanShowPlaceholder(editor: LexicalEditor, slot?: symbol): boolean {
|
|
17
|
+
const [canShowPlaceholder, setCanShowPlaceholder] = useState(
|
|
18
|
+
() => canShowPlaceholderFromCurrentEditorState(editor),
|
|
19
|
+
subSlot(slot, 'ucsp:state'),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
useLayoutEffect(
|
|
23
|
+
() => {
|
|
24
|
+
function resetCanShowPlaceholder() {
|
|
25
|
+
setCanShowPlaceholder(canShowPlaceholderFromCurrentEditorState(editor));
|
|
26
|
+
}
|
|
27
|
+
resetCanShowPlaceholder();
|
|
28
|
+
return mergeRegister(
|
|
29
|
+
editor.registerUpdateListener(() => resetCanShowPlaceholder()),
|
|
30
|
+
editor.registerEditableListener(() => resetCanShowPlaceholder()),
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
[editor],
|
|
34
|
+
subSlot(slot, 'ucsp:le'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return canShowPlaceholder;
|
|
38
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Ported from @lexical/react/src/shared/useCharacterLimit.ts. The overflow-wrapping
|
|
2
|
+
// logic ($wrapOverflowedNodes / $mergePrevious / findOffset) is framework-agnostic
|
|
3
|
+
// and copied verbatim. Only the hook wrapper changes: `optional` is an OPTIONAL user
|
|
4
|
+
// arg, so the trailing compiler-injected slot is found with splitSlot, and each of
|
|
5
|
+
// the two useEffects gets a distinct sub-slot.
|
|
6
|
+
import type { LexicalEditor, LexicalNode } from 'lexical';
|
|
7
|
+
|
|
8
|
+
import invariant from '@lexical/internal/invariant';
|
|
9
|
+
import { $createOverflowNode, $isOverflowNode, OverflowNode } from '@lexical/overflow';
|
|
10
|
+
import { $rootTextContent } from '@lexical/text';
|
|
11
|
+
import { $dfsWithSlots, $unwrapNode } from '@lexical/utils';
|
|
12
|
+
import {
|
|
13
|
+
$findMatchingParent,
|
|
14
|
+
$getSelection,
|
|
15
|
+
$getSlotHost,
|
|
16
|
+
$isElementNode,
|
|
17
|
+
$isLeafNode,
|
|
18
|
+
$isRangeSelection,
|
|
19
|
+
$isTextNode,
|
|
20
|
+
$setSelection,
|
|
21
|
+
COMMAND_PRIORITY_LOW,
|
|
22
|
+
DELETE_CHARACTER_COMMAND,
|
|
23
|
+
HISTORY_MERGE_TAG,
|
|
24
|
+
mergeRegister,
|
|
25
|
+
} from 'lexical';
|
|
26
|
+
import { useEffect } from 'octane';
|
|
27
|
+
|
|
28
|
+
import { splitSlot, subSlot } from './internal';
|
|
29
|
+
|
|
30
|
+
type OptionalProps = {
|
|
31
|
+
remainingCharacters?: (characters: number) => void;
|
|
32
|
+
strlen?: (input: string) => number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function useCharacterLimit(...args: any[]): void {
|
|
36
|
+
const [user, slot] = splitSlot(args);
|
|
37
|
+
const editor = user[0] as LexicalEditor;
|
|
38
|
+
const maxCharacters = user[1] as number;
|
|
39
|
+
const optional = (user[2] as OptionalProps) ?? Object.freeze({});
|
|
40
|
+
|
|
41
|
+
const strlen = optional.strlen ?? ((input: string) => input.length);
|
|
42
|
+
const remainingCharacters =
|
|
43
|
+
optional.remainingCharacters ??
|
|
44
|
+
(() => {
|
|
45
|
+
return;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
useEffect(
|
|
49
|
+
() => {
|
|
50
|
+
if (!editor.hasNodes([OverflowNode])) {
|
|
51
|
+
invariant(false, 'useCharacterLimit: OverflowNode not registered on editor');
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[editor],
|
|
55
|
+
subSlot(slot, 'ucl:nodes'),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
useEffect(
|
|
59
|
+
() => {
|
|
60
|
+
let text = editor.read('latest', $rootTextContent);
|
|
61
|
+
let lastComputedTextLength = 0;
|
|
62
|
+
|
|
63
|
+
return mergeRegister(
|
|
64
|
+
editor.registerTextContentListener((currentText: string) => {
|
|
65
|
+
text = currentText;
|
|
66
|
+
}),
|
|
67
|
+
editor.registerUpdateListener(({ dirtyLeaves, dirtyElements }) => {
|
|
68
|
+
const isComposing = editor.isComposing();
|
|
69
|
+
const hasContentChanges = dirtyLeaves.size > 0 || dirtyElements.size > 0;
|
|
70
|
+
|
|
71
|
+
if (isComposing || !hasContentChanges) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const textLength = strlen(text);
|
|
76
|
+
const textLengthAboveThreshold =
|
|
77
|
+
textLength > maxCharacters ||
|
|
78
|
+
(lastComputedTextLength !== null && lastComputedTextLength > maxCharacters);
|
|
79
|
+
const diff = maxCharacters - textLength;
|
|
80
|
+
|
|
81
|
+
remainingCharacters(diff);
|
|
82
|
+
|
|
83
|
+
if (lastComputedTextLength === null || textLengthAboveThreshold) {
|
|
84
|
+
const offset = findOffset(text, maxCharacters, strlen);
|
|
85
|
+
editor.update(
|
|
86
|
+
() => {
|
|
87
|
+
$wrapOverflowedNodes(offset);
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
tag: HISTORY_MERGE_TAG,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lastComputedTextLength = textLength;
|
|
96
|
+
}),
|
|
97
|
+
editor.registerCommand(
|
|
98
|
+
DELETE_CHARACTER_COMMAND,
|
|
99
|
+
(isBackward) => {
|
|
100
|
+
const selection = $getSelection();
|
|
101
|
+
if (!$isRangeSelection(selection)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const anchorNode = selection.anchor.getNode();
|
|
105
|
+
const overflow = anchorNode.getParent();
|
|
106
|
+
const overflowParent = overflow ? overflow.getParent() : null;
|
|
107
|
+
const parentNext = overflowParent ? overflowParent.getNextSibling() : null;
|
|
108
|
+
selection.deleteCharacter(isBackward);
|
|
109
|
+
if (overflowParent && overflowParent.isEmpty()) {
|
|
110
|
+
overflowParent.remove();
|
|
111
|
+
} else if ($isElementNode(parentNext) && parentNext.isEmpty()) {
|
|
112
|
+
parentNext.remove();
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
COMMAND_PRIORITY_LOW,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
[editor, maxCharacters, remainingCharacters, strlen],
|
|
121
|
+
subSlot(slot, 'ucl:track'),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findOffset(
|
|
126
|
+
text: string,
|
|
127
|
+
maxCharacters: number,
|
|
128
|
+
strlen: (input: string) => number,
|
|
129
|
+
): number {
|
|
130
|
+
let offsetUtf16 = 0;
|
|
131
|
+
let offset = 0;
|
|
132
|
+
|
|
133
|
+
if (typeof Intl.Segmenter === 'function') {
|
|
134
|
+
const segmenter = new Intl.Segmenter();
|
|
135
|
+
const graphemes = segmenter.segment(text);
|
|
136
|
+
|
|
137
|
+
for (const { segment: grapheme } of graphemes) {
|
|
138
|
+
const nextOffset = offset + strlen(grapheme);
|
|
139
|
+
|
|
140
|
+
if (nextOffset > maxCharacters) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
offset = nextOffset;
|
|
145
|
+
offsetUtf16 += grapheme.length;
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const codepoints = Array.from(text);
|
|
149
|
+
const codepointsLength = codepoints.length;
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < codepointsLength; i++) {
|
|
152
|
+
const codepoint = codepoints[i];
|
|
153
|
+
const nextOffset = offset + strlen(codepoint);
|
|
154
|
+
|
|
155
|
+
if (nextOffset > maxCharacters) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
offset = nextOffset;
|
|
160
|
+
offsetUtf16 += codepoint.length;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return offsetUtf16;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function $wrapOverflowedNodes(offset: number): void {
|
|
168
|
+
const dfsNodes = $dfsWithSlots();
|
|
169
|
+
const dfsNodesLength = dfsNodes.length;
|
|
170
|
+
let accumulatedLength = 0;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < dfsNodesLength; i += 1) {
|
|
173
|
+
const { node } = dfsNodes[i];
|
|
174
|
+
|
|
175
|
+
const isSlotValueLeaf = $isLeafNode(node) && $getSlotHost(node) !== null;
|
|
176
|
+
const needsOverflowParent =
|
|
177
|
+
$isLeafNode(node) && !isSlotValueLeaf && !$findMatchingParent(node, $isOverflowNode);
|
|
178
|
+
|
|
179
|
+
if ($isOverflowNode(node)) {
|
|
180
|
+
const previousLength = accumulatedLength;
|
|
181
|
+
const nextLength = accumulatedLength + node.getTextContentSize();
|
|
182
|
+
|
|
183
|
+
if (nextLength <= offset) {
|
|
184
|
+
const parent = node.getParent();
|
|
185
|
+
const previousSibling = node.getPreviousSibling();
|
|
186
|
+
const nextSibling = node.getNextSibling();
|
|
187
|
+
$unwrapNode(node);
|
|
188
|
+
const selection = $getSelection();
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
$isRangeSelection(selection) &&
|
|
192
|
+
(!selection.anchor.getNode().isAttached() || !selection.focus.getNode().isAttached())
|
|
193
|
+
) {
|
|
194
|
+
if ($isTextNode(previousSibling)) {
|
|
195
|
+
previousSibling.select();
|
|
196
|
+
} else if ($isTextNode(nextSibling)) {
|
|
197
|
+
nextSibling.select();
|
|
198
|
+
} else if (parent !== null) {
|
|
199
|
+
parent.select();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else if (previousLength < offset) {
|
|
203
|
+
const descendant = node.getFirstDescendant();
|
|
204
|
+
const descendantLength = descendant !== null ? descendant.getTextContentSize() : 0;
|
|
205
|
+
const previousPlusDescendantLength = previousLength + descendantLength;
|
|
206
|
+
const firstDescendantIsSimpleText = $isTextNode(descendant) && descendant.isSimpleText();
|
|
207
|
+
const firstDescendantDoesNotOverflow = previousPlusDescendantLength <= offset;
|
|
208
|
+
|
|
209
|
+
if (firstDescendantIsSimpleText || firstDescendantDoesNotOverflow) {
|
|
210
|
+
$unwrapNode(node);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else if (isSlotValueLeaf) {
|
|
214
|
+
accumulatedLength += node.getTextContentSize();
|
|
215
|
+
} else if (needsOverflowParent) {
|
|
216
|
+
const previousAccumulatedLength = accumulatedLength;
|
|
217
|
+
accumulatedLength += node.getTextContentSize();
|
|
218
|
+
|
|
219
|
+
if (accumulatedLength > offset && !$isOverflowNode(node.getParent())) {
|
|
220
|
+
const previousSelection = $getSelection();
|
|
221
|
+
let overflowNode;
|
|
222
|
+
|
|
223
|
+
if (previousAccumulatedLength < offset && $isTextNode(node) && node.isSimpleText()) {
|
|
224
|
+
const [, overflowedText] = node.splitText(offset - previousAccumulatedLength);
|
|
225
|
+
overflowNode = $wrapNode(overflowedText);
|
|
226
|
+
} else {
|
|
227
|
+
overflowNode = $wrapNode(node);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (previousSelection !== null) {
|
|
231
|
+
$setSelection(previousSelection);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$mergePrevious(overflowNode);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function $wrapNode(node: LexicalNode): OverflowNode {
|
|
241
|
+
const overflowNode = $createOverflowNode();
|
|
242
|
+
node.replace(overflowNode);
|
|
243
|
+
overflowNode.append(node);
|
|
244
|
+
return overflowNode;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function $mergePrevious(overflowNode: OverflowNode): void {
|
|
248
|
+
const previousNode = overflowNode.getPreviousSibling();
|
|
249
|
+
|
|
250
|
+
if (!$isOverflowNode(previousNode)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const firstChild = overflowNode.getFirstChild();
|
|
255
|
+
const previousNodeChildren = previousNode.getChildren();
|
|
256
|
+
const previousNodeChildrenLength = previousNodeChildren.length;
|
|
257
|
+
|
|
258
|
+
if (firstChild === null) {
|
|
259
|
+
overflowNode.append(...previousNodeChildren);
|
|
260
|
+
} else {
|
|
261
|
+
for (let i = 0; i < previousNodeChildrenLength; i++) {
|
|
262
|
+
firstChild.insertBefore(previousNodeChildren[i]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const selection = $getSelection();
|
|
267
|
+
|
|
268
|
+
if ($isRangeSelection(selection)) {
|
|
269
|
+
const anchor = selection.anchor;
|
|
270
|
+
const anchorNode = anchor.getNode();
|
|
271
|
+
const focus = selection.focus;
|
|
272
|
+
const focusNode = anchor.getNode();
|
|
273
|
+
|
|
274
|
+
if (anchorNode.is(previousNode)) {
|
|
275
|
+
anchor.set(overflowNode.getKey(), anchor.offset, 'element');
|
|
276
|
+
} else if (anchorNode.is(overflowNode)) {
|
|
277
|
+
anchor.set(overflowNode.getKey(), previousNodeChildrenLength + anchor.offset, 'element');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (focusNode.is(previousNode)) {
|
|
281
|
+
focus.set(overflowNode.getKey(), focus.offset, 'element');
|
|
282
|
+
} else if (focusNode.is(overflowNode)) {
|
|
283
|
+
focus.set(overflowNode.getKey(), previousNodeChildrenLength + focus.offset, 'element');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
previousNode.remove();
|
|
288
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { MenuResolution } from './menuShared';
|
|
2
|
+
|
|
3
|
+
import { getScrollParent } from '@lexical/utils';
|
|
4
|
+
import { getDOMShadowRoots } from 'lexical';
|
|
5
|
+
import { useEffect } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { useLexicalComposerContext } from '../LexicalComposerContext';
|
|
8
|
+
import { isTriggerVisibleInNearestScrollContainer } from './menuShared';
|
|
9
|
+
|
|
10
|
+
// Ported from shared/LexicalMenu.tsx — repositions an open menu on scroll / resize.
|
|
11
|
+
// A single base hook (useEffect), so the caller's slot is forwarded directly.
|
|
12
|
+
export function useDynamicPositioning(
|
|
13
|
+
resolution: MenuResolution | null,
|
|
14
|
+
targetElement: HTMLElement | null,
|
|
15
|
+
onReposition: () => void,
|
|
16
|
+
onVisibilityChange?: (isInView: boolean) => void,
|
|
17
|
+
slot?: symbol,
|
|
18
|
+
) {
|
|
19
|
+
const [editor] = useLexicalComposerContext();
|
|
20
|
+
useEffect(
|
|
21
|
+
() => {
|
|
22
|
+
if (targetElement != null && resolution != null) {
|
|
23
|
+
const rootElement = editor.getRootElement();
|
|
24
|
+
const rootScrollParent =
|
|
25
|
+
rootElement != null ? getScrollParent(rootElement, false) : document.body;
|
|
26
|
+
let ticking = false;
|
|
27
|
+
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
|
|
28
|
+
targetElement,
|
|
29
|
+
rootScrollParent,
|
|
30
|
+
);
|
|
31
|
+
const handleScroll = function () {
|
|
32
|
+
if (!ticking) {
|
|
33
|
+
window.requestAnimationFrame(function () {
|
|
34
|
+
onReposition();
|
|
35
|
+
ticking = false;
|
|
36
|
+
});
|
|
37
|
+
ticking = true;
|
|
38
|
+
}
|
|
39
|
+
const isInView = isTriggerVisibleInNearestScrollContainer(
|
|
40
|
+
targetElement,
|
|
41
|
+
rootScrollParent,
|
|
42
|
+
);
|
|
43
|
+
if (isInView !== previousIsInView) {
|
|
44
|
+
previousIsInView = isInView;
|
|
45
|
+
if (onVisibilityChange != null) {
|
|
46
|
+
onVisibilityChange(isInView);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const resizeObserver = new ResizeObserver(onReposition);
|
|
51
|
+
window.addEventListener('resize', onReposition);
|
|
52
|
+
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
|
|
53
|
+
const shadowRootSource = rootElement ?? targetElement;
|
|
54
|
+
const enclosingShadowRoots = getDOMShadowRoots(shadowRootSource);
|
|
55
|
+
for (const root of enclosingShadowRoots) {
|
|
56
|
+
root.addEventListener('scroll', handleScroll, { capture: true, passive: true });
|
|
57
|
+
}
|
|
58
|
+
resizeObserver.observe(targetElement);
|
|
59
|
+
return () => {
|
|
60
|
+
resizeObserver.unobserve(targetElement);
|
|
61
|
+
window.removeEventListener('resize', onReposition);
|
|
62
|
+
document.removeEventListener('scroll', handleScroll, true);
|
|
63
|
+
for (const root of enclosingShadowRoots) {
|
|
64
|
+
root.removeEventListener('scroll', handleScroll, true);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[targetElement, editor, onVisibilityChange, onReposition, resolution],
|
|
70
|
+
slot,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { MenuRef, MenuResolution } from './menuShared';
|
|
2
|
+
|
|
3
|
+
import { CAN_USE_DOM } from 'lexical';
|
|
4
|
+
import { useCallback, useEffect, useRef } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { useLexicalComposerContext } from '../LexicalComposerContext';
|
|
7
|
+
import { resolveMenuParent, setContainerDivAttributes } from './menuShared';
|
|
8
|
+
import { useDynamicPositioning } from './useDynamicPositioning';
|
|
9
|
+
import { splitSlot, subSlot } from './internal';
|
|
10
|
+
|
|
11
|
+
// Ported from shared/LexicalMenu.tsx. Composes several base hooks + a nested hook
|
|
12
|
+
// (useDynamicPositioning); each gets a distinct sub-slot. `className`/`parent`/
|
|
13
|
+
// `shouldIncludePageYOffset` are optional, so the trailing slot is found via
|
|
14
|
+
// splitSlot (positional resolution would mistake it for an optional arg).
|
|
15
|
+
export function useMenuAnchorRef(...args: any[]): MenuRef {
|
|
16
|
+
const [user, slot] = splitSlot(args);
|
|
17
|
+
const resolution = user[0] as MenuResolution | null;
|
|
18
|
+
const setResolution = user[1] as (r: MenuResolution | null) => void;
|
|
19
|
+
const className = user[2] as string | undefined;
|
|
20
|
+
const parent = user[3] as HTMLElement | undefined;
|
|
21
|
+
const shouldIncludePageYOffset = user[4] !== undefined ? (user[4] as boolean) : true;
|
|
22
|
+
|
|
23
|
+
const [editor] = useLexicalComposerContext();
|
|
24
|
+
const resolvedParent = parent ?? resolveMenuParent(editor);
|
|
25
|
+
const initialAnchorElement = CAN_USE_DOM ? document.createElement('div') : null;
|
|
26
|
+
const anchorElementRef = useRef<HTMLElement | null>(
|
|
27
|
+
initialAnchorElement,
|
|
28
|
+
subSlot(slot, 'uma:ref'),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const positionMenu = useCallback(
|
|
32
|
+
() => {
|
|
33
|
+
if (anchorElementRef.current === null || resolvedParent === undefined) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
anchorElementRef.current.style.top = anchorElementRef.current.style.bottom;
|
|
37
|
+
const rootElement = editor.getRootElement();
|
|
38
|
+
const containerDiv = anchorElementRef.current;
|
|
39
|
+
// octane portals leave a `<!--portal-->` marker as firstChild, so reach for
|
|
40
|
+
// the first ELEMENT child (the rendered menu) to measure/position.
|
|
41
|
+
const menuEle = containerDiv.firstElementChild as HTMLElement;
|
|
42
|
+
if (rootElement !== null && resolution !== null) {
|
|
43
|
+
const { left, top, width, height } = resolution.getRect();
|
|
44
|
+
const anchorHeight = anchorElementRef.current.offsetHeight;
|
|
45
|
+
containerDiv.style.top = `${
|
|
46
|
+
top + anchorHeight + 3 + (shouldIncludePageYOffset ? window.pageYOffset : 0)
|
|
47
|
+
}px`;
|
|
48
|
+
containerDiv.style.left = `${left + window.pageXOffset}px`;
|
|
49
|
+
containerDiv.style.height = `${height}px`;
|
|
50
|
+
containerDiv.style.width = `${width}px`;
|
|
51
|
+
if (menuEle !== null) {
|
|
52
|
+
menuEle.style.top = `${top}`;
|
|
53
|
+
const menuRect = menuEle.getBoundingClientRect();
|
|
54
|
+
const menuHeight = menuRect.height;
|
|
55
|
+
const menuWidth = menuRect.width;
|
|
56
|
+
const rootElementRect = rootElement.getBoundingClientRect();
|
|
57
|
+
if (left + menuWidth > rootElementRect.right) {
|
|
58
|
+
containerDiv.style.left = `${rootElementRect.right - menuWidth + window.pageXOffset}px`;
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
(top + menuHeight > window.innerHeight || top + menuHeight > rootElementRect.bottom) &&
|
|
62
|
+
top - rootElementRect.top > menuHeight + height
|
|
63
|
+
) {
|
|
64
|
+
containerDiv.style.top = `${
|
|
65
|
+
top - menuHeight - height + (shouldIncludePageYOffset ? window.pageYOffset : 0)
|
|
66
|
+
}px`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!containerDiv.isConnected) {
|
|
70
|
+
setContainerDivAttributes(containerDiv, className);
|
|
71
|
+
resolvedParent.append(containerDiv);
|
|
72
|
+
}
|
|
73
|
+
containerDiv.setAttribute('id', 'typeahead-menu');
|
|
74
|
+
rootElement.setAttribute('aria-controls', 'typeahead-menu');
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[editor, resolution, shouldIncludePageYOffset, className, resolvedParent],
|
|
78
|
+
subSlot(slot, 'uma:pos'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
useEffect(
|
|
82
|
+
() => {
|
|
83
|
+
const rootElement = editor.getRootElement();
|
|
84
|
+
if (resolution !== null) {
|
|
85
|
+
positionMenu();
|
|
86
|
+
}
|
|
87
|
+
return () => {
|
|
88
|
+
if (rootElement !== null) {
|
|
89
|
+
rootElement.removeAttribute('aria-controls');
|
|
90
|
+
}
|
|
91
|
+
const containerDiv = anchorElementRef.current;
|
|
92
|
+
if (containerDiv !== null && containerDiv.isConnected) {
|
|
93
|
+
containerDiv.remove();
|
|
94
|
+
containerDiv.removeAttribute('id');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
[editor, positionMenu, resolution],
|
|
99
|
+
subSlot(slot, 'uma:eff'),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const onVisibilityChange = useCallback(
|
|
103
|
+
(isInView: boolean) => {
|
|
104
|
+
if (resolution !== null) {
|
|
105
|
+
if (!isInView) {
|
|
106
|
+
setResolution(null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[resolution, setResolution],
|
|
111
|
+
subSlot(slot, 'uma:vis'),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
useDynamicPositioning(
|
|
115
|
+
resolution,
|
|
116
|
+
anchorElementRef.current,
|
|
117
|
+
positionMenu,
|
|
118
|
+
onVisibilityChange,
|
|
119
|
+
subSlot(slot, 'uma:dyn'),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Append the anchor container immediately (first render only).
|
|
123
|
+
if (initialAnchorElement != null && initialAnchorElement === anchorElementRef.current) {
|
|
124
|
+
setContainerDivAttributes(initialAnchorElement, className);
|
|
125
|
+
if (resolvedParent != null) {
|
|
126
|
+
resolvedParent.append(initialAnchorElement);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return anchorElementRef;
|
|
131
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LexicalEditor } from 'lexical';
|
|
2
|
+
|
|
3
|
+
import { registerDragonSupport } from '@lexical/dragon';
|
|
4
|
+
import { registerPlainText } from '@lexical/plain-text';
|
|
5
|
+
import { mergeRegister } from 'lexical';
|
|
6
|
+
import { useLayoutEffect } from 'octane';
|
|
7
|
+
|
|
8
|
+
// Ported from @lexical/react/src/shared/usePlainTextSetup.ts.
|
|
9
|
+
export function usePlainTextSetup(editor: LexicalEditor, slot?: symbol): void {
|
|
10
|
+
useLayoutEffect(
|
|
11
|
+
() => mergeRegister(registerPlainText(editor), registerDragonSupport(editor)),
|
|
12
|
+
[editor],
|
|
13
|
+
slot,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LexicalEditor } from 'lexical';
|
|
2
|
+
|
|
3
|
+
import { registerDragonSupport } from '@lexical/dragon';
|
|
4
|
+
import { registerRichText } from '@lexical/rich-text';
|
|
5
|
+
import { mergeRegister } from 'lexical';
|
|
6
|
+
import { useLayoutEffect } from 'octane';
|
|
7
|
+
|
|
8
|
+
// Ported from @lexical/react/src/shared/useRichTextSetup.ts. Composes one base hook
|
|
9
|
+
// (useLayoutEffect), so the caller's slot is forwarded directly.
|
|
10
|
+
export function useRichTextSetup(editor: LexicalEditor, slot?: symbol): void {
|
|
11
|
+
useLayoutEffect(
|
|
12
|
+
() => mergeRegister(registerRichText(editor), registerDragonSupport(editor)),
|
|
13
|
+
[editor],
|
|
14
|
+
slot,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Ambient declaration so the `.ts` barrel + hooks can import this package's `.tsrx`
|
|
2
|
+
// components/nodes. tsgo doesn't understand `.tsrx` (the octane compiler does), so a
|
|
3
|
+
// single ambient module declares them all — no per-component `.d.ts` sidecar.
|
|
4
|
+
declare module '*.tsrx';
|