Skip to content

Commit 3b8c58b

Browse files
authored
refactor: use content model to detect rich text (#5109)
Ref #3632 Here I finally got rid from "rich-text-child" necessity. With html elements in the tree it will no longer work so we need a new way to work with rich text. Added a few new utilities to content model which detect rich text by text inside or any textual tags. Span and links can still be detected as containers rather than children when not surrounded by text and other textual elements.
1 parent 81a5eff commit 3b8c58b

27 files changed

+697
-450
lines changed

apps/builder/app/builder/features/navigator/navigator-tree.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ import {
6767
selectInstance,
6868
} from "~/shared/awareness";
6969
import { findClosestContainer, isTreeMatching } from "~/shared/matcher";
70-
import { isTreeSatisfyingContentModel } from "~/shared/content-model";
70+
import {
71+
isRichTextContent,
72+
isTreeSatisfyingContentModel,
73+
} from "~/shared/content-model";
7174

7275
type TreeItemAncestor =
7376
| undefined
@@ -511,17 +514,18 @@ const canDrag = (instance: Instance, instanceSelector: InstanceSelector) => {
511514
return false;
512515
}
513516

514-
const meta = $registeredComponentMetas.get().get(instance.component);
515-
if (meta === undefined) {
516-
return true;
517-
}
518-
const detachable = meta.type !== "rich-text-child";
519-
if (detachable === false) {
517+
const isContent = isRichTextContent({
518+
instanceSelector,
519+
instances: $instances.get(),
520+
props: $props.get(),
521+
metas: $registeredComponentMetas.get(),
522+
});
523+
if (isContent) {
520524
toast.error(
521525
"This instance can not be moved outside of its parent component."
522526
);
523527
}
524-
return detachable;
528+
return !isContent;
525529
};
526530

527531
const canDrop = (

apps/builder/app/builder/features/workspace/canvas-tools/outline/block-utils.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,13 @@ export const insertTemplateAt = (
191191

192192
const selectors: InstanceSelector[] = [];
193193

194-
findAllEditableInstanceSelector(
195-
selectedInstanceSelector,
196-
data.instances,
197-
$registeredComponentMetas.get(),
198-
selectors
199-
);
194+
findAllEditableInstanceSelector({
195+
instanceSelector: selectedInstanceSelector,
196+
instances: data.instances,
197+
props: data.props,
198+
metas: $registeredComponentMetas.get(),
199+
results: selectors,
200+
});
200201

201202
const editableInstanceSelector = selectors[0];
202203

apps/builder/app/builder/shared/commands.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ import {
3636
import { $selectedInstancePath, selectInstance } from "~/shared/awareness";
3737
import { openCommandPanel } from "../features/command-panel";
3838
import { builderApi } from "~/shared/builder-api";
39-
import {
40-
findClosestNonTextualContainer,
41-
isInstanceDetachable,
42-
isTreeMatching,
43-
} from "~/shared/matcher";
39+
import { isInstanceDetachable, isTreeMatching } from "~/shared/matcher";
4440
import { getSetting, setSetting } from "./client-settings";
4541
import { findAvailableVariables } from "~/shared/data-variables";
4642
import { atom } from "nanostores";
47-
import { isTreeSatisfyingContentModel } from "~/shared/content-model";
43+
import {
44+
findClosestNonTextualContainer,
45+
isRichTextContent,
46+
isTreeSatisfyingContentModel,
47+
} from "~/shared/content-model";
4848

4949
export const $styleSourceInputElement = atom<HTMLInputElement | undefined>();
5050

@@ -155,8 +155,13 @@ export const wrapIn = (component: string) => {
155155
const metas = $registeredComponentMetas.get();
156156
try {
157157
updateWebstudioData((data) => {
158-
const meta = metas.get(selectedInstance.component);
159-
if (meta?.type === "rich-text-child") {
158+
const isContent = isRichTextContent({
159+
instanceSelector: selectedItem.instanceSelector,
160+
instances: data.instances,
161+
props: data.props,
162+
metas,
163+
});
164+
if (isContent) {
160165
toast.error(`Cannot wrap textual content`);
161166
throw Error("Abort transaction");
162167
}
@@ -206,12 +211,13 @@ export const unwrap = () => {
206211
const [selectedItem, parentItem] = instancePath;
207212
try {
208213
updateWebstudioData((data) => {
209-
const nonTextualIndex = findClosestNonTextualContainer({
214+
const instanceSelector = findClosestNonTextualContainer({
210215
metas: $registeredComponentMetas.get(),
216+
props: data.props,
211217
instances: data.instances,
212218
instanceSelector: selectedItem.instanceSelector,
213219
});
214-
if (nonTextualIndex !== 0) {
220+
if (selectedItem.instanceSelector.join() !== instanceSelector.join()) {
215221
toast.error(`Cannot unwrap textual instance`);
216222
throw Error("Abort transaction");
217223
}

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

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { StoryFn, Meta } from "@storybook/react";
55
import { action } from "@storybook/addon-actions";
66
import { Box, Button, Flex } from "@webstudio-is/design-system";
77
import { theme } from "@webstudio-is/design-system";
8-
import type { Instance, Instances } from "@webstudio-is/sdk";
8+
import type { Instance, Instances, Props } from "@webstudio-is/sdk";
99
import { $, renderData } from "@webstudio-is/template";
1010
import {
1111
$instances,
@@ -58,6 +58,8 @@ const instances: Instances = new Map([
5858
]),
5959
]);
6060

61+
const props: Props = new Map();
62+
6163
export const Basic: StoryFn<typeof TextEditor> = ({ onChange }) => {
6264
const state = useStore($textToolbar);
6365

@@ -129,6 +131,7 @@ export const Basic: StoryFn<typeof TextEditor> = ({ onChange }) => {
129131
<TextEditor
130132
rootInstanceSelector={["1"]}
131133
instances={instances}
134+
props={props}
132135
contentEditable={<ContentEditable />}
133136
onChange={onChange}
134137
onSelectInstance={(instanceId) =>
@@ -176,6 +179,7 @@ export const CursorPositioning: StoryFn<typeof TextEditor> = ({ onChange }) => {
176179
<TextEditor
177180
rootInstanceSelector={["1"]}
178181
instances={instances}
182+
props={props}
179183
contentEditable={<ContentEditable />}
180184
onChange={onChange}
181185
onSelectInstance={(instanceId) =>
@@ -240,20 +244,8 @@ export const CursorPositioningUpDown: StoryFn<typeof TextEditor> = () => {
240244

241245
$registeredComponentMetas.set(
242246
new Map([
243-
[
244-
"Box",
245-
{
246-
type: "container",
247-
icon: "icon",
248-
},
249-
],
250-
[
251-
"Bold",
252-
{
253-
type: "rich-text-child",
254-
icon: "icon",
255-
},
256-
],
247+
["Box", { type: "container", icon: "icon" }],
248+
["Bold", { type: "container", icon: "icon" }],
257249
])
258250
);
259251

@@ -311,6 +303,7 @@ export const CursorPositioningUpDown: StoryFn<typeof TextEditor> = () => {
311303
}
312304
rootInstanceSelector={["boxAId", "bodyId"]}
313305
instances={instances}
306+
props={props}
314307
contentEditable={<ContentEditable />}
315308
onChange={(data) => {
316309
setState((prev) => {
@@ -336,6 +329,7 @@ export const CursorPositioningUpDown: StoryFn<typeof TextEditor> = () => {
336329
editable={textEditingInstanceSelector?.selector[0] === "boxBId"}
337330
rootInstanceSelector={["boxBId", "bodyId"]}
338331
instances={instances}
332+
props={props}
339333
contentEditable={<ContentEditable />}
340334
onChange={(data) => {
341335
setState((prev) => {

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
5151

5252
import { nanoid } from "nanoid";
5353
import { createRegularStyleSheet } from "@webstudio-is/css-engine";
54-
import type { Instance, Instances } from "@webstudio-is/sdk";
54+
import type { Instance, Instances, Props } from "@webstudio-is/sdk";
5555
import {
5656
collapsedAttribute,
5757
idAttribute,
@@ -1440,6 +1440,7 @@ const onError = (error: Error) => {
14401440
type TextEditorProps = {
14411441
rootInstanceSelector: InstanceSelector;
14421442
instances: Instances;
1443+
props: Props;
14431444
contentEditable: JSX.Element;
14441445
editable?: boolean;
14451446
onChange: (instancesList: Instance[]) => void;
@@ -1520,6 +1521,7 @@ const AnyKeyDownPlugin = ({
15201521
export const TextEditor = ({
15211522
rootInstanceSelector: rootInstanceSelectorUnstable,
15221523
instances,
1524+
props,
15231525
contentEditable,
15241526
editable,
15251527
onChange,
@@ -1623,12 +1625,13 @@ export const TextEditor = ({
16231625
}
16241626

16251627
const editableInstanceSelectors: InstanceSelector[] = [];
1626-
findAllEditableInstanceSelector(
1627-
[rootInstanceId],
1628+
findAllEditableInstanceSelector({
1629+
instanceSelector: [rootInstanceId],
16281630
instances,
1631+
props,
16291632
metas,
1630-
editableInstanceSelectors
1631-
);
1633+
results: editableInstanceSelectors,
1634+
});
16321635

16331636
const currentIndex = editableInstanceSelectors.findIndex(
16341637
(instanceSelector) => {

apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
$registeredComponentMetas,
4646
$selectedInstanceRenderState,
4747
findBlockSelector,
48+
$props,
4849
} from "~/shared/nano-states";
4950
import { $textEditingInstanceSelector } from "~/shared/nano-states";
5051
import {
@@ -410,6 +411,7 @@ export const WebstudioComponentCanvas = forwardRef<
410411
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
411412
const instanceId = instance.id;
412413
const instances = useStore($instances);
414+
const allProps = useStore($props);
413415
const metas = useStore($registeredComponentMetas);
414416

415417
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
@@ -551,6 +553,7 @@ export const WebstudioComponentCanvas = forwardRef<
551553
<TextEditor
552554
rootInstanceSelector={instanceSelector}
553555
instances={instances}
556+
props={allProps}
554557
contentEditable={
555558
<ContentEditable
556559
placeholder={getEditableComponentPlaceholder(

apps/builder/app/canvas/instance-selection.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { getInstanceSelectorFromElement } from "~/shared/dom-utils";
2-
import { findClosestEditableInstanceSelector } from "~/shared/instance-utils";
32
import {
43
$hoveredInstanceOutline,
54
$hoveredInstanceSelector,
65
$instances,
76
$isContentMode,
7+
$props,
88
$registeredComponentMetas,
99
} from "~/shared/nano-states";
1010
import { $textEditingInstanceSelector } from "~/shared/nano-states";
1111
import { emitCommand } from "./shared/commands";
1212
import { shallowEqual } from "shallow-equal";
1313
import { $awareness, selectInstance } from "~/shared/awareness";
14+
import { findClosestRichText } from "~/shared/content-model";
1415

1516
const isElementBeingEdited = (element: Element) => {
1617
if (element.closest("[contenteditable=true]")) {
@@ -67,11 +68,12 @@ const handleEdit = (event: MouseEvent) => {
6768

6869
const instances = $instances.get();
6970

70-
let editableInstanceSelector = findClosestEditableInstanceSelector(
71+
let editableInstanceSelector = findClosestRichText({
7172
instanceSelector,
7273
instances,
73-
$registeredComponentMetas.get()
74-
);
74+
props: $props.get(),
75+
metas: $registeredComponentMetas.get(),
76+
});
7577

7678
// Do not allow edit bindable text instances with expression children in Content Mode
7779
if (editableInstanceSelector !== undefined && $isContentMode.get()) {

apps/builder/app/canvas/shared/commands.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { FORMAT_TEXT_COMMAND } from "lexical";
22
import { TOGGLE_LINK_COMMAND } from "@lexical/link";
33
import { createCommandsEmitter } from "~/shared/commands-emitter";
44
import { getElementByInstanceSelector } from "~/shared/dom-utils";
5-
import {
6-
findClosestEditableInstanceSelector,
7-
findAllEditableInstanceSelector,
8-
} from "~/shared/instance-utils";
5+
import { findAllEditableInstanceSelector } from "~/shared/instance-utils";
96
import {
107
$instances,
8+
$props,
119
$registeredComponentMetas,
1210
$selectedInstanceSelector,
1311
$textEditingInstanceSelector,
@@ -22,6 +20,7 @@ import {
2220
import { selectInstance } from "~/shared/awareness";
2321
import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils";
2422
import { deleteSelectedInstance } from "~/builder/shared/commands";
23+
import { findClosestRichText } from "~/shared/content-model";
2524

2625
export const { emitCommand, subscribeCommands } = createCommandsEmitter({
2726
source: "canvas",
@@ -59,21 +58,23 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
5958
return;
6059
}
6160

62-
let editableInstanceSelector = findClosestEditableInstanceSelector(
63-
selectedInstanceSelector,
64-
$instances.get(),
65-
$registeredComponentMetas.get()
66-
);
61+
let editableInstanceSelector = findClosestRichText({
62+
instanceSelector: selectedInstanceSelector,
63+
instances: $instances.get(),
64+
props: $props.get(),
65+
metas: $registeredComponentMetas.get(),
66+
});
6767

6868
if (editableInstanceSelector === undefined) {
6969
const selectors: InstanceSelector[] = [];
7070

71-
findAllEditableInstanceSelector(
72-
selectedInstanceSelector,
73-
$instances.get(),
74-
$registeredComponentMetas.get(),
75-
selectors
76-
);
71+
findAllEditableInstanceSelector({
72+
instanceSelector: selectedInstanceSelector,
73+
instances: $instances.get(),
74+
props: $props.get(),
75+
metas: $registeredComponentMetas.get(),
76+
results: selectors,
77+
});
7778

7879
if (selectors.length === 0) {
7980
$textEditingInstanceSelector.set(undefined);

apps/builder/app/canvas/shared/styles.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,13 @@ const subscribeContentEditModeHelperStyles = () => {
206206
const editableInstanceSelectors: InstanceSelector[] = [];
207207
const instances = $instances.get();
208208

209-
findAllEditableInstanceSelector(
210-
[rootInstanceId],
209+
findAllEditableInstanceSelector({
210+
instanceSelector: [rootInstanceId],
211211
instances,
212-
$registeredComponentMetas.get(),
213-
editableInstanceSelectors
214-
);
212+
props: $props.get(),
213+
metas: $registeredComponentMetas.get(),
214+
results: editableInstanceSelectors,
215+
});
215216

216217
// Group IDs into chunks of 20 since :is() allows for more efficient grouping
217218
const chunkSize = 20;

0 commit comments

Comments
 (0)