Skip to content

Commit 5746590

Browse files
authored
refactor: merge tree and navigator tree (#4122)
Separated navigator tree configuration into 2 modules complicated any improvements. Here merged into single module.
1 parent c81b3d5 commit 5746590

File tree

2 files changed

+267
-294
lines changed

2 files changed

+267
-294
lines changed

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

Lines changed: 267 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
import { useCallback, useMemo } from "react";
1+
import { useCallback, useMemo, useRef } from "react";
2+
import { nanoid } from "nanoid";
3+
import { mergeRefs } from "@react-aria/utils";
24
import { useStore } from "@nanostores/react";
35
import { shallowEqual } from "shallow-equal";
4-
import { toast } from "@webstudio-is/design-system";
5-
import { collectionComponent } from "@webstudio-is/react-sdk";
6+
import {
7+
toast,
8+
Tree,
9+
TreeItemLabel,
10+
TreeItemBody,
11+
type TreeItemRenderProps,
12+
styled,
13+
theme,
14+
getNodeVars,
15+
rawTheme,
16+
Tooltip,
17+
SmallIconButton,
18+
} from "@webstudio-is/design-system";
19+
import { EyeconClosedIcon, EyeconOpenIcon } from "@webstudio-is/icons";
20+
import { collectionComponent, showAttribute } from "@webstudio-is/react-sdk";
621
import {
722
$hoveredInstanceSelector,
823
$instances,
@@ -11,6 +26,11 @@ import {
1126
$textEditingInstanceSelector,
1227
$registeredComponentMetas,
1328
$dragAndDropState,
29+
$editingItemSelector,
30+
$propValuesByInstanceSelector,
31+
getIndexedInstanceId,
32+
$props,
33+
$propsIndex,
1434
} from "~/shared/nano-states";
1535
import type { InstanceSelector } from "~/shared/tree-utils";
1636
import {
@@ -21,7 +41,136 @@ import {
2141
isInstanceDetachable,
2242
getComponentTemplateData,
2343
} from "~/shared/instance-utils";
24-
import { InstanceTree } from "./tree";
44+
import { useContentEditable } from "~/shared/dom-hooks";
45+
import { getInstanceLabel } from "~/shared/instance-utils";
46+
import { serverSyncStore } from "~/shared/sync";
47+
import type { Instance } from "@webstudio-is/sdk";
48+
import { MetaIcon } from "./meta-icon";
49+
50+
const TreeItem = ({
51+
prefix,
52+
value,
53+
isEditing,
54+
isEditable = false,
55+
onChangeValue,
56+
onChangeEditing,
57+
}: {
58+
isEditable: boolean;
59+
isEditing: boolean;
60+
prefix?: React.ReactNode;
61+
value: string;
62+
onChangeValue: (value: string) => void;
63+
onChangeEditing: (isEditing: boolean) => void;
64+
}) => {
65+
const editableRef = useRef<HTMLDivElement | null>(null);
66+
const { ref, handlers } = useContentEditable({
67+
value,
68+
isEditable,
69+
isEditing,
70+
onChangeValue: (value: string) => {
71+
onChangeValue(value);
72+
const button = editableRef.current?.closest(
73+
"[data-item-button-id]"
74+
) as HTMLElement;
75+
button?.focus();
76+
},
77+
onChangeEditing,
78+
});
79+
80+
return (
81+
<EditableTreeItemLabel
82+
ref={mergeRefs(editableRef, ref)}
83+
{...handlers}
84+
isEditing={isEditing}
85+
prefix={prefix}
86+
>
87+
{value}
88+
</EditableTreeItemLabel>
89+
);
90+
};
91+
92+
const EditableTreeItemLabel = styled(TreeItemLabel, {
93+
variants: {
94+
isEditing: {
95+
true: {
96+
background: theme.colors.backgroundControls,
97+
padding: theme.spacing[3],
98+
borderRadius: theme.spacing[3],
99+
color: theme.colors.hiContrast,
100+
outline: "none",
101+
cursor: "auto",
102+
textOverflow: "clip",
103+
userSelect: "text",
104+
},
105+
},
106+
},
107+
});
108+
109+
const ShowToggle = ({
110+
show,
111+
onChange,
112+
}: {
113+
show: boolean;
114+
onChange: (show: boolean) => void;
115+
}) => {
116+
return (
117+
<Tooltip
118+
// If you are changing it, change the other one too
119+
content="Removes the instance from the DOM. Breakpoints have no effect on this setting."
120+
disableHoverableContent
121+
variant="wrapped"
122+
>
123+
<SmallIconButton
124+
aria-label="Show"
125+
onClick={() => onChange(show ? false : true)}
126+
icon={show ? <EyeconOpenIcon /> : <EyeconClosedIcon />}
127+
/>
128+
</Tooltip>
129+
);
130+
};
131+
132+
const updateShowProp = (instanceId: Instance["id"], value: boolean) => {
133+
serverSyncStore.createTransaction([$props], (props) => {
134+
const { propsByInstanceId } = $propsIndex.get();
135+
const instanceProps = propsByInstanceId.get(instanceId);
136+
let showProp = instanceProps?.find((prop) => prop.name === showAttribute);
137+
138+
if (showProp === undefined) {
139+
showProp = {
140+
id: nanoid(),
141+
instanceId,
142+
name: showAttribute,
143+
type: "boolean",
144+
value,
145+
};
146+
}
147+
if (showProp.type === "boolean") {
148+
props.set(showProp.id, {
149+
...showProp,
150+
value,
151+
});
152+
}
153+
});
154+
};
155+
156+
const updateInstanceLabel = (instanceId: Instance["id"], value: string) => {
157+
serverSyncStore.createTransaction([$instances], (instances) => {
158+
const instance = instances.get(instanceId);
159+
if (instance === undefined) {
160+
return;
161+
}
162+
instance.label = value;
163+
});
164+
};
165+
166+
const canLeaveParent = ([instanceId]: InstanceSelector) => {
167+
const instance = $instances.get().get(instanceId);
168+
if (instance === undefined) {
169+
return false;
170+
}
171+
const meta = $registeredComponentMetas.get().get(instance.component);
172+
return meta?.type !== "rich-text-child";
173+
};
25174

26175
export const NavigatorTree = () => {
27176
const selectedInstanceSelector = useStore($selectedInstanceSelector);
@@ -30,6 +179,8 @@ export const NavigatorTree = () => {
30179
const instances = useStore($instances);
31180
const metas = useStore($registeredComponentMetas);
32181
const state = useStore($dragAndDropState);
182+
const editingItemSelector = useStore($editingItemSelector);
183+
const propValues = useStore($propValuesByInstanceSelector);
33184

34185
const dragPayload = state.dragPayload;
35186

@@ -117,17 +268,128 @@ export const NavigatorTree = () => {
117268
$textEditingInstanceSelector.set(undefined);
118269
}, []);
119270

271+
const getItemChildren = useCallback(
272+
(instanceSelector: InstanceSelector) => {
273+
const [instanceId, parentId] = instanceSelector;
274+
let instance = instances.get(instanceId);
275+
const children: Instance[] = [];
276+
277+
// put fake collection item instances according to collection data
278+
if (instance?.component === collectionComponent) {
279+
const data = propValues
280+
.get(JSON.stringify(instanceSelector))
281+
?.get("data");
282+
// create items only when collection has content
283+
if (Array.isArray(data) && instance.children.length > 0) {
284+
data.forEach((_item, index) => {
285+
children.push({
286+
type: "instance",
287+
id: getIndexedInstanceId(instanceId, index),
288+
component: "ws:collection-item",
289+
children: [],
290+
});
291+
});
292+
}
293+
return children;
294+
}
295+
296+
// put parent children as own when parent is a collection
297+
if (instance === undefined) {
298+
const parentInstance = instances.get(parentId);
299+
if (parentInstance?.component === collectionComponent) {
300+
instance = parentInstance;
301+
}
302+
}
303+
if (instance === undefined) {
304+
return children;
305+
}
306+
307+
for (const child of instance.children) {
308+
if (child.type !== "id") {
309+
continue;
310+
}
311+
const childInstance = instances.get(child.value);
312+
if (childInstance === undefined) {
313+
continue;
314+
}
315+
children.push(childInstance);
316+
}
317+
return children;
318+
},
319+
[instances, propValues]
320+
);
321+
322+
const renderItem = useCallback(
323+
(props: TreeItemRenderProps<Instance>) => {
324+
const { itemData, itemSelector } = props;
325+
const meta = metas.get(itemData.component);
326+
if (meta === undefined) {
327+
return <></>;
328+
}
329+
const label = getInstanceLabel(itemData, meta);
330+
const isEditing = shallowEqual(itemSelector, editingItemSelector);
331+
const instanceProps = propValues.get(JSON.stringify(itemSelector));
332+
const show = Boolean(instanceProps?.get(showAttribute) ?? true);
333+
334+
return (
335+
<TreeItemBody
336+
{...props}
337+
selectionEvent="focus"
338+
suffix={
339+
<ShowToggle
340+
show={show}
341+
onChange={(show) => {
342+
updateShowProp(itemData.id, show);
343+
}}
344+
/>
345+
}
346+
>
347+
<TreeItem
348+
isEditable={true}
349+
isEditing={isEditing}
350+
onChangeValue={(val) => {
351+
updateInstanceLabel(props.itemData.id, val);
352+
}}
353+
onChangeEditing={(isEditing) => {
354+
$editingItemSelector.set(
355+
isEditing === true ? props.itemSelector : undefined
356+
);
357+
}}
358+
prefix={<MetaIcon icon={meta.icon} />}
359+
value={label}
360+
/>
361+
</TreeItemBody>
362+
);
363+
},
364+
[metas, editingItemSelector, propValues]
365+
);
366+
120367
if (rootInstance === undefined) {
121368
return;
122369
}
123370

124371
return (
125-
<InstanceTree
372+
<Tree
126373
root={rootInstance}
127374
selectedItemSelector={selectedInstanceSelector}
128375
highlightedItemSelector={hoveredInstanceSelector}
129376
dragItemSelector={dragItemSelector}
130377
dropTarget={state.dropTarget}
378+
canLeaveParent={canLeaveParent}
379+
getItemChildren={getItemChildren}
380+
getItemProps={({ itemData, itemSelector }) => {
381+
const props = propValues.get(JSON.stringify(itemSelector));
382+
const opacity = props?.get(showAttribute) === false ? 0.4 : undefined;
383+
const color =
384+
itemData.component === "Slot"
385+
? rawTheme.colors.foregroundReusable
386+
: undefined;
387+
return {
388+
style: getNodeVars({ color, opacity }),
389+
};
390+
}}
391+
renderItem={renderItem}
392+
editingItemId={editingItemSelector?.[0]}
131393
isItemHidden={isItemHidden}
132394
findClosestDroppableIndex={findClosestDroppableIndex}
133395
onSelect={handleSelect}

0 commit comments

Comments
 (0)