Skip to content

Commit 9cf443a

Browse files
authored
feat: Support new link selection in the text editor (#4509)
## Description Same as #4505 but supports just created links too ref #3994 ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent f395bbe commit 9cf443a

File tree

5 files changed

+119
-18
lines changed

5 files changed

+119
-18
lines changed

apps/builder/app/canvas/features/text-editor/interop.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ test("convert lexical to instances updates", async () => {
184184
}
185185

186186
const updates = editor.getEditorState().read(() => {
187-
return $convertToUpdates(treeRootInstance, refs);
187+
return $convertToUpdates(treeRootInstance, refs, new Map());
188188
});
189189

190190
expect(updates).toEqual([

apps/builder/app/canvas/features/text-editor/interop.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,39 @@ const $writeUpdates = (
2929
node: ElementNode,
3030
instanceChildren: Instance["children"],
3131
instancesList: Instance[],
32-
refs: Refs
32+
refs: Refs,
33+
newLinkKeyToInstanceId: Refs
3334
) => {
3435
const children = node.getChildren();
3536
for (const child of children) {
3637
if ($isParagraphNode(child)) {
37-
$writeUpdates(child, instanceChildren, instancesList, refs);
38+
$writeUpdates(
39+
child,
40+
instanceChildren,
41+
instancesList,
42+
refs,
43+
newLinkKeyToInstanceId
44+
);
3845
}
3946
if ($isLineBreakNode(child)) {
4047
instanceChildren.push({ type: "text", value: "\n" });
4148
}
4249
if ($isLinkNode(child)) {
4350
const key = child.getKey();
44-
const id = refs.get(key) ?? nanoid();
51+
const id = refs.get(key) ?? newLinkKeyToInstanceId.get(key) ?? nanoid();
4552
refs.set(key, id);
4653
instanceChildren.push({
4754
type: "id",
4855
value: id,
4956
});
5057
const childChildren: Instance["children"] = [];
51-
$writeUpdates(child, childChildren, instancesList, refs);
58+
$writeUpdates(
59+
child,
60+
childChildren,
61+
instancesList,
62+
refs,
63+
newLinkKeyToInstanceId
64+
);
5265
instancesList.push({
5366
type: "instance",
5467
id,
@@ -99,7 +112,11 @@ const $writeUpdates = (
99112
}
100113
};
101114

102-
export const $convertToUpdates = (treeRootInstance: Instance, refs: Refs) => {
115+
export const $convertToUpdates = (
116+
treeRootInstance: Instance,
117+
refs: Refs,
118+
newLinkKeyToInstanceId: Refs
119+
) => {
103120
const treeRootInstanceChildren: Instance["children"] = [];
104121
const instancesList: Instance[] = [
105122
{
@@ -108,7 +125,13 @@ export const $convertToUpdates = (treeRootInstance: Instance, refs: Refs) => {
108125
},
109126
];
110127
const root = $getRoot();
111-
$writeUpdates(root, treeRootInstanceChildren, instancesList, refs);
128+
$writeUpdates(
129+
root,
130+
treeRootInstanceChildren,
131+
instancesList,
132+
refs,
133+
newLinkKeyToInstanceId
134+
);
112135
return instancesList;
113136
};
114137

apps/builder/app/canvas/features/text-editor/text-editor.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
$createTextNode,
3636
KEY_DOWN_COMMAND,
3737
COMMAND_PRIORITY_NORMAL,
38+
type NodeKey,
3839
} from "lexical";
3940
import { LinkNode } from "@lexical/link";
4041
import { LexicalComposer } from "@lexical/react/LexicalComposer";
@@ -43,6 +44,7 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
4344
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
4445
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
4546
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
47+
4648
import { nanoid } from "nanoid";
4749
import { createRegularStyleSheet } from "@webstudio-is/css-engine";
4850
import type { Instance, Instances } from "@webstudio-is/sdk";
@@ -71,7 +73,11 @@ import {
7173
} from "~/shared/dom-utils";
7274
import deepEqual from "fast-deep-equal";
7375
import { setDataCollapsed } from "~/canvas/collapsed";
74-
import { $selectedPage, selectInstance } from "~/shared/awareness";
76+
import {
77+
$selectedPage,
78+
addTemporaryInstance,
79+
selectInstance,
80+
} from "~/shared/awareness";
7581
import { shallowEqual } from "shallow-equal";
7682

7783
const BindInstanceToNodePlugin = ({
@@ -176,7 +182,21 @@ const OnChangeOnBlurPlugin = ({
176182
return null;
177183
};
178184

179-
const LinkSelectionPlugin = () => {
185+
const getNodeKeyFromDOMNode = (
186+
dom: Node,
187+
editor: LexicalEditor
188+
): NodeKey | undefined => {
189+
const prop = `__lexicalKey_${editor._key}`;
190+
return (dom as Node & Record<typeof prop, NodeKey | undefined>)[prop];
191+
};
192+
193+
const LinkSelectionPlugin = ({
194+
rootInstanceSelector,
195+
registerNewLink,
196+
}: {
197+
rootInstanceSelector: InstanceSelector;
198+
registerNewLink: (key: NodeKey, instanceId: string) => void;
199+
}) => {
180200
const [editor] = useLexicalComposerContext();
181201
const [preservedSelection] = useState($selectedInstanceSelector.get());
182202

@@ -189,14 +209,43 @@ const LinkSelectionPlugin = () => {
189209
({ editorState }) => {
190210
editorState.read(() => {
191211
const selection = $getSelection();
192-
// console.log(selection);
193212
if (!$isRangeSelection(selection)) {
194213
return false;
195214
}
196215
const key = selection.anchor.getNode().getKey();
197216

198217
const elt = editor.getElementByKey(key);
199-
const link = elt?.closest(`a[${selectorIdAttribute}]`);
218+
let link = elt?.closest(`a[${selectorIdAttribute}]`);
219+
const newLink = elt?.closest(`a`);
220+
221+
while (newLink != null && link == null) {
222+
// new link detected
223+
224+
// https://github.com/facebook/lexical/blob/b7fa4cf673869dac0c2e0c1fe667e71e72ff6adb/packages/lexical/src/LexicalUtils.ts#L465
225+
const key = getNodeKeyFromDOMNode(newLink, editor);
226+
if (key === undefined) {
227+
console.error("Key not found for node", newLink);
228+
break;
229+
}
230+
231+
// Register new link
232+
const instanceId = nanoid();
233+
234+
newLink.setAttribute(idAttribute, instanceId);
235+
// We set id + root selector here, for simplicity
236+
// This solves hover behavior during mouseMove for editable child outline
237+
// @todo: A normal selector must be used, but it would require significantly more code to detect the tree structure.
238+
newLink.setAttribute(
239+
selectorIdAttribute,
240+
[instanceId, ...rootInstanceSelector].join(",")
241+
);
242+
243+
registerNewLink(key, instanceId);
244+
245+
link = newLink;
246+
247+
break;
248+
}
200249

201250
if (link == null) {
202251
if (
@@ -232,7 +281,7 @@ const LinkSelectionPlugin = () => {
232281
return () => {
233282
removeUpdateListener();
234283
};
235-
}, [editor, preservedSelection]);
284+
}, [editor, preservedSelection, registerNewLink, rootInstanceSelector]);
236285

237286
return null;
238287
};
@@ -950,6 +999,7 @@ export const TextEditor = ({
950999
const [paragraphClassName] = useState(() => `a${nanoid()}`);
9511000
const [italicClassName] = useState(() => `a${nanoid()}`);
9521001
const lastSavedStateJsonRef = useRef<SerializedEditorState | null>(null);
1002+
const [newLinkKeyToInstanceId] = useState(() => new Map());
9531003

9541004
const handleChange = useEffectEvent((editorState: EditorState) => {
9551005
editorState.read(() => {
@@ -961,7 +1011,10 @@ export const TextEditor = ({
9611011
return;
9621012
}
9631013

964-
onChange($convertToUpdates(treeRootInstance, refs));
1014+
onChange(
1015+
$convertToUpdates(treeRootInstance, refs, newLinkKeyToInstanceId)
1016+
);
1017+
newLinkKeyToInstanceId.clear();
9651018
lastSavedStateJsonRef.current = jsonState;
9661019
}
9671020

@@ -1113,6 +1166,19 @@ export const TextEditor = ({
11131166
$hoveredInstanceSelector.set(undefined);
11141167
}, []);
11151168

1169+
const registerNewLink = useCallback(
1170+
(key: NodeKey, instanceId: string) => {
1171+
newLinkKeyToInstanceId.set(key, instanceId);
1172+
addTemporaryInstance({
1173+
id: instanceId,
1174+
component: "RichTextLink",
1175+
type: "instance",
1176+
children: [],
1177+
});
1178+
},
1179+
[newLinkKeyToInstanceId]
1180+
);
1181+
11161182
return (
11171183
<LexicalComposer initialConfig={initialConfig}>
11181184
<RemoveParagaphsPlugin />
@@ -1142,7 +1208,10 @@ export const TextEditor = ({
11421208
<SwitchBlockPlugin onNext={handleNext} />
11431209
<OnChangeOnBlurPlugin onChange={handleChange} />
11441210
<InitCursorPlugin />
1145-
<LinkSelectionPlugin />
1211+
<LinkSelectionPlugin
1212+
rootInstanceSelector={rootInstanceSelector}
1213+
registerNewLink={registerNewLink}
1214+
/>
11461215
<AnyKeyDownPlugin onKeyDown={handleAnyKeydown} />
11471216
<InitialJSONStatePlugin
11481217
onInitialState={(json) => {

apps/builder/app/shared/awareness.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export const $selectedPage = computed(
3131
}
3232
);
3333

34+
export const $temporaryInstances = atom<Instances>(new Map());
35+
export const addTemporaryInstance = (instance: Instance) => {
36+
$temporaryInstances.get().set(instance.id, instance);
37+
$temporaryInstances.set($temporaryInstances.get());
38+
};
39+
3440
export const $virtualInstances = computed($selectedPage, (selectedPage) => {
3541
const virtualInstances: Instances = new Map();
3642
if (selectedPage) {
@@ -45,15 +51,16 @@ export const $virtualInstances = computed($selectedPage, (selectedPage) => {
4551
});
4652

4753
export const $selectedInstance = computed(
48-
[$instances, $virtualInstances, $awareness],
49-
(instances, virtualInstances, awareness) => {
54+
[$instances, $virtualInstances, $temporaryInstances, $awareness],
55+
(instances, virtualInstances, tempInstances, awareness) => {
5056
if (awareness?.instanceSelector === undefined) {
5157
return;
5258
}
5359
const [selectedInstanceId] = awareness.instanceSelector;
5460
return (
5561
instances.get(selectedInstanceId) ??
56-
virtualInstances.get(selectedInstanceId)
62+
virtualInstances.get(selectedInstanceId) ??
63+
tempInstances.get(selectedInstanceId)
5764
);
5865
}
5966
);

apps/builder/app/shared/sync/sync-stores.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
$modifierKeys,
4949
} from "~/shared/nano-states";
5050
import { $ephemeralStyles } from "~/canvas/stores";
51-
import { $awareness } from "../awareness";
51+
import { $awareness, $temporaryInstances } from "../awareness";
5252
import {
5353
ImmerhinSyncObject,
5454
NanostoresSyncObject,
@@ -88,6 +88,8 @@ export const createObjectPool = () => {
8888
$selectedInstanceSelector
8989
),
9090
new NanostoresSyncObject("awareness", $awareness),
91+
new NanostoresSyncObject("temporaryInstances", $temporaryInstances),
92+
9193
new NanostoresSyncObject("project", $project),
9294
new NanostoresSyncObject("dataSourceVariables", $dataSourceVariables),
9395
new NanostoresSyncObject("resourceValues", $resourceValues),

0 commit comments

Comments
 (0)