Skip to content

Commit 13684d0

Browse files
Merge pull request #198 from gridaco/staging
Auto expand & Auto focus of selection on Layer Hierarchy
2 parents e367c24 + 7862eb2 commit 13684d0

File tree

16 files changed

+2463
-42
lines changed

16 files changed

+2463
-42
lines changed

editor/components/code-editor/monaco.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function MonacoEditor({ path, ...props }: MonacoEditorProps) {
101101
// if change is caused by formatter, ignore.
102102
return;
103103
}
104-
props.onChange(...v);
104+
props.onChange?.(...v);
105105
}}
106106
options={{
107107
...props.options,

editor/components/editor/editor-hierarchy-layers/editor-layer-hierarchy-tree.tsx

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from "react";
1+
import React, {
2+
useCallback,
3+
useEffect,
4+
useLayoutEffect,
5+
useMemo,
6+
useState,
7+
} from "react";
28
import { TreeView } from "@editor-ui/editor";
39
import {
410
LayerRow,
@@ -16,11 +22,36 @@ import {
1622
FlattenedDisplayItemNode,
1723
} from "./editor-layer-heriarchy-controller";
1824
import type { ReflectSceneNode } from "@design-sdk/figma-node";
25+
import type { IVirtualizedList } from "@editor-ui/listview";
26+
import useMeasure from "react-use-measure";
27+
import { p } from "@tree-/q";
1928

2029
// TODO:
2130
// - add navigate context menu
2231
// - add go to main component
23-
// - add reveal and focus to selected layers
32+
33+
function useAutoFocus({
34+
ref,
35+
layers,
36+
targets,
37+
}: {
38+
ref: React.RefObject<IVirtualizedList>;
39+
targets: string[];
40+
layers: FlattenedDisplayItemNode[][];
41+
}) {
42+
// TODO: we use useLayoutEffect to focus to the selection, because it relates to auto expand & layer calculation.
43+
// this can be simplified. and we can use plain old useEffect.
44+
useLayoutEffect(() => {
45+
// auto focus to selection
46+
const focusnode = targets[0];
47+
if (focusnode) {
48+
const index = layers.flat().findIndex((i) => i.id == focusnode);
49+
if (index) {
50+
ref.current?.scrollToIndex(index);
51+
}
52+
}
53+
}, [ref, targets, layers]);
54+
}
2455

2556
/**
2657
*
@@ -35,13 +66,18 @@ export function DesignLayerHierarchy({
3566
rootNodeIDs?: string[];
3667
expandAll?: boolean;
3768
}) {
69+
const [sizeRef, { height, width }] = useMeasure({
70+
debounce: { scroll: 100, resize: 100 },
71+
});
3872
const [state] = useEditorState();
3973
const { selectedNodes, selectedPage, design } = state;
4074
const { highlightLayer, highlightedLayer } = useWorkspace();
4175
const dispatch = useDispatch();
4276

4377
const [expands, setExpands] = useState<string[]>(state?.selectedNodes ?? []);
4478

79+
const ref = React.useRef<IVirtualizedList>(null);
80+
4581
// get the root nodes (if the rootNodeIDs is not specified, use the selected page's children)
4682
let roots: ReflectSceneNode[] = [];
4783
if (rootNodeIDs?.length > 0) {
@@ -63,6 +99,13 @@ export function DesignLayerHierarchy({
6399
: [];
64100
}, [roots, state?.selectedNodes, expands]);
65101

102+
useAutoFocus({
103+
ref,
104+
layers,
105+
targets: selectedNodes,
106+
});
107+
108+
// exapnd all nodes
66109
useEffect(() => {
67110
if (expandAll) {
68111
const ids = layers.reduce((acc, item) => {
@@ -73,6 +116,26 @@ export function DesignLayerHierarchy({
73116
}
74117
}, [layers, expandAll]);
75118

119+
// automatically expand the selected nodes' parents
120+
useEffect(() => {
121+
const newexpands = [];
122+
123+
// loop through all roots
124+
for (const child of roots) {
125+
// if the node contains the selected node, add to expands.
126+
selectedNodes.forEach((id) => {
127+
const path = p(id, { data: child });
128+
if (path.length > 0) {
129+
newexpands.push(...path);
130+
}
131+
});
132+
}
133+
134+
setExpands(
135+
Array.from(new Set([...expands, ...newexpands])).filter(Boolean)
136+
);
137+
}, [selectedNodes]);
138+
76139
const renderItem = useCallback(
77140
({
78141
id,
@@ -123,13 +186,27 @@ export function DesignLayerHierarchy({
123186
);
124187

125188
return (
126-
<TreeView.Root
127-
data={layers.flat()}
128-
keyExtractor={useCallback((item: any) => item.id, [])}
129-
renderItem={renderItem}
130-
/>
189+
<div
190+
style={{
191+
width: "100%",
192+
height: "100%",
193+
}}
194+
ref={sizeRef}
195+
>
196+
<TreeView.Root
197+
ref={ref}
198+
data={layers.flat()}
199+
keyExtractor={useCallback((item: any) => item.id, [])}
200+
renderItem={renderItem}
201+
scrollable
202+
expandable
203+
virtualized={{
204+
width: width,
205+
height: height,
206+
}}
207+
/>
208+
</div>
131209
);
132-
//
133210
}
134211

135212
/**

editor/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@mui/material": "^5.6.1",
3030
"@radix-ui/react-toast": "^1.1.1",
3131
"@reflect-blocks/figma-embed": "^0.0.5",
32+
"@tree-/q": "^0.0.0",
3233
"@use-gesture/react": "^10.2.11",
3334
"@visx/gradient": "^1.7.0",
3435
"@visx/group": "^1.7.0",

editor/scaffolds/inspector/section-code.tsx

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,43 @@ import { copy } from "utils/clipboard";
1616
import styled from "@emotion/styled";
1717
import { GearIcon, CodeIcon } from "@radix-ui/react-icons";
1818
import { useOpenPreferences, usePreferences } from "@code-editor/preferences";
19+
import { colors } from "theme";
20+
21+
/**
22+
* a debounced state for hiding the monaco view, since it blinks after loading
23+
*/
24+
function useExtraLoading(result: Result | null, delay = 800) {
25+
const [loading, setLoading] = useState(true);
26+
27+
// set loading to false after delay, if result is givven.
28+
useEffect(() => {
29+
if (!result?.code) {
30+
return;
31+
}
32+
33+
const timer = setTimeout(() => {
34+
setLoading(false);
35+
}, delay);
36+
37+
return () => {
38+
clearTimeout(timer);
39+
};
40+
}, [result?.code]);
41+
42+
// reset loading to true on result change.
43+
useEffect(() => {
44+
setLoading(true);
45+
}, [result?.code]);
46+
47+
return loading;
48+
}
1949

2050
export function CodeSection() {
2151
const { config: preferences } = usePreferences();
2252
const { target, root } = useTargetContainer();
2353
const [result, setResult] = useState<Result>(null);
54+
const loading = useExtraLoading(result);
55+
2456
const dispatch = useDispatch();
2557

2658
const on_result = (result: Result) => {
@@ -82,43 +114,64 @@ export function CodeSection() {
82114
</div>
83115
</PropertyGroupHeader>
84116
<CliIntegrationSnippet node={target} />
85-
{code ? (
86-
<>
87-
<MonacoEditor
88-
readonly
89-
width={"100%"}
90-
value={code.raw}
91-
height={viewheight}
92-
fold_comments_on_load
93-
path={dummy_file_name_map[preferences.framework.framework]}
94-
options={{
95-
lineNumbers: "off",
96-
glyphMargin: false,
97-
minimap: { enabled: false },
98-
showFoldingControls: "mouseover",
99-
guides: {
100-
indentation: false,
101-
highlightActiveIndentation: false,
102-
},
103-
}}
104-
/>
105-
</>
106-
) : (
107-
<div
108-
style={{
109-
display: "flex",
110-
justifyContent: "center",
111-
alignItems: "center",
112-
height: viewheight,
113-
}}
114-
>
117+
<CodeView
118+
style={{
119+
height: viewheight,
120+
}}
121+
>
122+
<div className="loading-overlay" data-loading={loading}>
115123
<CircularProgress size={24} />
116124
</div>
117-
)}
125+
<MonacoEditor
126+
readonly
127+
width={"100%"}
128+
value={code?.raw ?? ""}
129+
height={"100%"}
130+
fold_comments_on_load
131+
path={dummy_file_name_map[preferences.framework.framework]}
132+
options={{
133+
lineNumbers: "off",
134+
glyphMargin: false,
135+
minimap: { enabled: false },
136+
showFoldingControls: "mouseover",
137+
guides: {
138+
indentation: false,
139+
highlightActiveIndentation: false,
140+
},
141+
}}
142+
/>
143+
</CodeView>
118144
</PropertyGroup>
119145
);
120146
}
121147

148+
const CodeView = styled.div`
149+
position: relative;
150+
151+
.loading-overlay {
152+
user-select: none;
153+
pointer-events: none;
154+
background-color: ${colors.color_editor_bg_on_dark};
155+
position: absolute;
156+
display: flex;
157+
justify-content: center;
158+
align-items: center;
159+
height: 100%;
160+
width: 100%;
161+
z-index: 9;
162+
opacity: 1;
163+
164+
&[data-loading="false"] {
165+
opacity: 0;
166+
transition: opacity 0.2s ease-in-out;
167+
}
168+
169+
&[data-loading="true"] {
170+
opacity: 1;
171+
}
172+
}
173+
`;
174+
122175
const dummy_file_name_map = {
123176
flutter: "main.dart",
124177
react: "app.tsx",

lib/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# libs (not part of workspace)

lib/tree-q/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Grida Inc
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

lib/tree-q/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./path/p";

lib/tree-q/jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
preset: "ts-jest",
3+
};

lib/tree-q/package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@tree-/q",
3+
"version": "0.0.0",
4+
"description": "Tree query utility",
5+
"homepage": "https://github.com/gridaco/code/tree/main/lib/tree-q",
6+
"repository": "https://github.com/gridaco/code",
7+
"author": "Grida Inc",
8+
"license": "MIT",
9+
"main": "dist/index.js",
10+
"scripts": {
11+
"clean": "rm -rf dist",
12+
"test": "jest",
13+
"build": "tsc",
14+
"prepack": "yarn run clean && yarn run build"
15+
},
16+
"dependencies": {},
17+
"devDependencies": {
18+
"@types/node": "^18.11.18",
19+
"jest": "^29.3.1",
20+
"typescript": "^4.9.4",
21+
"ts-jest": "^29.0.3"
22+
},
23+
"publishConfig": {
24+
"access": "public"
25+
},
26+
"files": [
27+
"readme.md",
28+
"LICENSE",
29+
"dist"
30+
]
31+
}

lib/tree-q/path/p.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { p } from "./p";
2+
3+
describe("p", () => {
4+
const data = {
5+
id: "1",
6+
children: [
7+
{
8+
id: "2",
9+
children: [
10+
{
11+
id: "3",
12+
children: [],
13+
},
14+
],
15+
},
16+
],
17+
};
18+
19+
test("returns the correct path", () => {
20+
expect(p("3", { data })).toEqual(["1", "2", "3"]);
21+
});
22+
23+
test("returns the correct path", () => {
24+
expect(p("2", { data })).toEqual(["1", "2"]);
25+
});
26+
27+
test("returns an empty array if id is not found", () => {
28+
expect(p("4", { data })).toEqual([]);
29+
});
30+
});

0 commit comments

Comments
 (0)