Skip to content

Commit 091ff6b

Browse files
committed
feat(graph): support edit key node in the graph view
1 parent 83ee8fc commit 091ff6b

12 files changed

Lines changed: 159 additions & 72 deletions

File tree

src/containers/editor/components/StatusBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function JsonPath() {
6868
const node = tree.node(id);
6969
setRevealPosition({
7070
treeNodeId: id,
71-
type: tree.isGraphNode(node) ? "node" : "key",
71+
type: tree.isGraphNode(node) ? "graphNode" : "keyValue",
7272
from: "statusBar",
7373
});
7474
}}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { memo, useState, useCallback } from "react";
2+
import { cn } from "@/lib/utils";
3+
import Popover from "./Popover";
4+
5+
interface EditableTextProps {
6+
classNames: string[];
7+
text: string;
8+
onDoubleClick: () => void;
9+
onClick: (e: React.MouseEvent) => void;
10+
onEdit: (value: string) => void;
11+
title: string;
12+
popoverWidth: number;
13+
isIterable?: boolean;
14+
widthInInput?: number;
15+
}
16+
17+
const EditableText = memo((props: EditableTextProps) => {
18+
const classNamesWithoutHl = props.classNames.filter((c) => c !== "search-highlight");
19+
const [isInput, setIsInput] = useState(false);
20+
const [content, setContent] = useState(props.text);
21+
22+
const callEdit = useCallback(() => {
23+
props.onEdit(content);
24+
setIsInput(false);
25+
}, [content, props]);
26+
27+
return (
28+
<Popover width={props.popoverWidth} hlClassNames={classNamesWithoutHl} text={content}>
29+
{isInput ? (
30+
<input
31+
className={cn(...classNamesWithoutHl)}
32+
style={{ width: props.widthInInput }}
33+
value={content}
34+
onClick={(e) => e.stopPropagation()}
35+
onChange={(e) => setContent(e.target.value)}
36+
onFocus={(e) => e.target.select()}
37+
autoFocus
38+
onBlur={callEdit}
39+
onKeyDown={(e) => {
40+
if (e.key === "Enter") {
41+
callEdit();
42+
}
43+
}}
44+
/>
45+
) : (
46+
<div
47+
className={cn("hover:bg-yellow-100", ...props.classNames)}
48+
title={props.title}
49+
onClick={props.onClick}
50+
onDoubleClick={() => {
51+
if (!props.isIterable) {
52+
props.onDoubleClick();
53+
setIsInput(true);
54+
}
55+
}}
56+
>
57+
{content}
58+
</div>
59+
)}
60+
</Popover>
61+
);
62+
});
63+
64+
EditableText.displayName = "EditableText";
65+
66+
export default EditableText;

src/containers/editor/graph/KV.tsx

Lines changed: 41 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { memo, useCallback, useState } from "react";
2+
import type { RevealType } from "@/lib/graph/types";
23
import { isIterableType, type NodeType } from "@/lib/parser/node";
34
import { cn } from "@/lib/utils";
45
import { useStatusStore } from "@/stores/statusStore";
56
import { useTree } from "@/stores/treeStore";
67
import { useTranslations } from "next-intl";
8+
import EditableText from "./EditableText";
79
import { SourceHandle } from "./Handle";
8-
import Popover from "./Popover";
910
import useClickNode from "./useClickNode";
1011

1112
interface KvProps {
@@ -26,20 +27,22 @@ interface KvProps {
2627

2728
const KV = memo((props: KvProps) => {
2829
const isIterable = isIterableType(props.nodeType);
29-
const keyClassNamesWithoutHighlight = props.keyClassNames.slice(0, 1);
30-
const valueClassNamesWithoutHighlight = props.valueClassNames.slice(0, 1);
3130

32-
const [isInput, setIsInput] = useState(false);
33-
const [content, setContent] = useState(props.valueText);
31+
const [inputMode, setInputMode] = useState<RevealType | "">("");
3432
const tree = useTree();
3533
const { onClick, cancelClickNode } = useClickNode();
3634
const t = useTranslations();
3735

3836
const addToEditQueue = useStatusStore((state) => state.addToEditQueue);
39-
const callEdit = useCallback(() => {
40-
setIsInput(false);
41-
addToEditQueue({ treeNodeId: props.id, value: content, version: tree.version });
42-
}, [props.id, content, tree.version]);
37+
const onEdit = useCallback(
38+
(value: string) => {
39+
if (inputMode) {
40+
addToEditQueue({ treeNodeId: props.id, type: inputMode, value, version: tree.version });
41+
setInputMode("");
42+
}
43+
},
44+
[props.id, inputMode, tree.version, addToEditQueue],
45+
);
4346

4447
return (
4548
<div
@@ -51,9 +54,9 @@ const KV = memo((props: KvProps) => {
5154
title={isIterable ? t("double_click_to_reveal_first_child") : ""}
5255
style={{ width: props.width }}
5356
data-tree-id={props.id}
54-
onClick={isInput ? undefined : (e) => onClick(e, props.id, "key", "graphClick")}
57+
onClick={inputMode ? undefined : (e) => onClick(e, props.id, "keyValue", "graphClick")}
5558
onDoubleClick={(e) => {
56-
if (!(isIterable && !isInput)) {
59+
if (!(isIterable && !inputMode)) {
5760
return;
5861
}
5962

@@ -68,50 +71,33 @@ const KV = memo((props: KvProps) => {
6871
}
6972
}}
7073
>
71-
<Popover width={props.width} hlClassNames={keyClassNamesWithoutHighlight} text={props.keyText}>
72-
<div className={cn("graph-k hover:bg-yellow-100", ...props.keyClassNames)}>{props.keyText}</div>
73-
</Popover>
74-
<Popover width={props.width} hlClassNames={valueClassNamesWithoutHighlight} text={content}>
75-
{
76-
// If in input mode, render an input field for editing the content
77-
isInput ? (
78-
<input
79-
className={cn("graph-v", ...valueClassNamesWithoutHighlight)}
80-
style={{ width: props.valueWidth }}
81-
value={content}
82-
// Stop the click event from propagating to prevent unwanted parent element clicks
83-
onClick={(e) => e.stopPropagation()}
84-
onChange={(e) => setContent(e.target.value)}
85-
onFocus={(e) => (e.target as HTMLInputElement).select()}
86-
autoFocus
87-
onBlur={callEdit}
88-
onKeyDown={(e) => {
89-
if (e.key === "Enter") {
90-
callEdit();
91-
}
92-
}}
93-
/>
94-
) : (
95-
// If not in input mode, render a div displaying the content
96-
<div
97-
className={cn("graph-v hover:bg-yellow-100", ...props.valueClassNames)}
98-
title={isIterable ? t("double_click_to_reveal_first_child") : t("double_click_to_enter_edit_mode")}
99-
onClick={(e) => onClick(e, props.id, "value", "graphClick")}
100-
// Double-click to enter input mode
101-
onDoubleClick={() => {
102-
if (isIterable) {
103-
return;
104-
}
105-
106-
cancelClickNode();
107-
setIsInput(true);
108-
}}
109-
>
110-
{content}
111-
</div>
112-
)
113-
}
114-
</Popover>
74+
<EditableText
75+
classNames={["graph-k", ...props.keyClassNames]}
76+
text={props.keyText}
77+
onDoubleClick={() => {
78+
cancelClickNode();
79+
setInputMode("key");
80+
}}
81+
onClick={(e) => onClick(e, props.id, "key", "graphClick")}
82+
onEdit={(value) => onEdit(value)}
83+
title={t("double_click_to_enter_edit_mode")}
84+
popoverWidth={props.width}
85+
widthInInput={props.keyWidth}
86+
/>
87+
<EditableText
88+
classNames={["graph-v", ...props.valueClassNames]}
89+
text={props.valueText}
90+
isIterable={isIterable}
91+
onDoubleClick={() => {
92+
cancelClickNode();
93+
setInputMode("value");
94+
}}
95+
onClick={(e) => onClick(e, props.id, "value", "graphClick")}
96+
onEdit={(value) => onEdit(value)}
97+
title={isIterable ? t("double_click_to_reveal_first_child") : t("double_click_to_enter_edit_mode")}
98+
widthInInput={props.valueWidth}
99+
popoverWidth={props.width}
100+
/>
115101
{props.hasChildren && (
116102
<SourceHandle id={props.keyText} indexInParent={props.index} isChildrenHidden={props.isChildrenHidden} />
117103
)}

src/lib/editor/editor.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,24 @@ export class EditorWrapper {
100100
return;
101101
}
102102

103-
const offset = type === "key" ? node.boundOffset : node.offset;
104-
const length = type === "key" ? node.boundLength : node.length;
103+
let offset = 0;
104+
let length = 0;
105+
106+
if (type === "graphNode" || type === "keyValue") {
107+
offset = node.boundOffset;
108+
length = node.boundLength;
109+
} else if (type === "key") {
110+
offset = node.boundOffset;
111+
length = node.keyLength + 2;
112+
} else if (type === "value") {
113+
offset = node.offset;
114+
length = node.length;
115+
}
116+
117+
if (length === 0) {
118+
return;
119+
}
120+
105121
this.revealOffset(offset + 1);
106122
const range = this.range(offset, length);
107123
this.editor.setSelection(range);
@@ -144,17 +160,21 @@ export class EditorWrapper {
144160
treeEdits = uniqueEdits.filter((edit) => edit.version === this.tree.version);
145161

146162
const nodes = treeEdits
147-
.map((edit) => ({ ...this.tree.node(edit.treeNodeId), newValue: edit.value }))
163+
.map((edit) => ({
164+
...this.tree.node(edit.treeNodeId),
165+
newValue: edit.value,
166+
editType: edit.type,
167+
}))
148168
.filter((node) => node);
149169
if (nodes.length === 0) {
150170
return;
151171
}
152172

153-
const edits = nodes.map((node) => ({
154-
text: node.type === "string" ? `"${node.newValue}"` : node.newValue,
155-
range: this.range(node.offset, node.length),
173+
const edits = nodes.map(({ editType, newValue, ...nd }) => ({
174+
text: editType === "key" || nd.type === "string" ? `"${newValue}"` : newValue,
175+
range: editType === "key" ? this.range(nd.boundOffset, nd.keyLength + 2) : this.range(nd.offset, nd.length),
156176
}));
157-
console.l("edit nodes: ", treeEdits, edits);
177+
console.l("edit nodes: ", treeEdits, nodes, edits);
158178

159179
this.editor.executeEdits(null, edits);
160180
this.editor.pushUndoStop();

src/lib/graph/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export function computeRevealPosition(
156156
let xOffset = 0;
157157
let yOffset = 0;
158158

159-
if (type !== "node") {
159+
if (type !== "graphNode") {
160160
const i = tree.node(parent!).childrenKeys?.indexOf(lastKey) ?? 0;
161161
yOffset = computeSourceHandleOffset(i);
162162
xOffset = type === "key" ? 0 : graphNode.data.width / 2;

src/lib/graph/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ export interface SubGraph {
110110
edges: EdgeWithData[];
111111
}
112112

113-
export type RevealType = "node" | "key" | "value";
113+
// The type of the node in the graph to be revealed.
114+
export type RevealType = "graphNode" | "keyValue" | "key" | "value";
114115
export type RevealFrom =
115116
| "editor"
116117
| "statusBar"

src/lib/graph/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function toGraphNodeId(treeNodeId: string): GraphNodeId {
1818

1919
export function getGraphNodeId(treeNodeId: string, type: RevealType) {
2020
const parentId = getParentId(treeNodeId);
21-
return toGraphNodeId(type === "node" ? treeNodeId : (parentId ?? ""));
21+
return toGraphNodeId(type === "graphNode" ? treeNodeId : (parentId ?? ""));
2222
}
2323

2424
export function newGraph(g?: Omit<Graph, "__type">): Graph {

src/lib/parser/node.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ export type NodeType = "object" | "array" | "string" | "number" | "boolean" | "n
1919

2020
export interface Node {
2121
id: string; // construct by json pointer with format of `$/a/b/c`
22-
type: NodeType;
22+
type: NodeType; // type of node. For property node, it is the type of value.
2323
offset: number; // offset of rawValue in the whole text
2424
length: number; // length of rawValue
25+
keyLength: number; // length of key without quotes (only property node have)
2526
boundOffset: number; // offset of bounding in the whole text
2627
boundLength: number; // length of bounding
2728
value?: any; // value with type (only leaf node have)

src/lib/parser/parse.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ interface ParseNode extends Node {
120120
path: jsonc.JSONPath;
121121
parent?: ParseNode;
122122
childrenOffset?: Record<string, number>;
123+
childrenKeyLength?: Record<string, number>;
123124
}
124125

125126
class Visitor {
@@ -146,12 +147,14 @@ class Visitor {
146147
type: "object",
147148
offset: 0,
148149
length: 0,
150+
keyLength: 0,
149151
boundOffset: 0,
150152
boundLength: 0,
151153
path: this.parentMeta.path,
152154
childrenKeys: [],
153155
childrenKey2Id: {},
154156
childrenOffset: {},
157+
childrenKeyLength: {},
155158
};
156159
}
157160

@@ -165,6 +168,7 @@ class Visitor {
165168
type,
166169
offset,
167170
length,
171+
keyLength: 0,
168172
boundOffset: offset,
169173
boundLength: length,
170174
path,
@@ -175,6 +179,7 @@ class Visitor {
175179
node.childrenKeys = [];
176180
node.childrenKey2Id = {};
177181
node.childrenOffset = {};
182+
node.childrenKeyLength = {};
178183
}
179184

180185
this.nodeMap[node.id] = node;
@@ -191,11 +196,15 @@ class Visitor {
191196
addChild(child: ParseNode) {
192197
const key = String(last(child.path));
193198
const childOffset = this.currentParent.childrenOffset?.[key];
199+
const childKeyLength = this.currentParent.childrenKeyLength?.[key];
194200

195201
if (childOffset !== undefined) {
196202
child.boundOffset = childOffset;
197203
computeAndSetBoundLength(child);
198204
}
205+
if (childKeyLength !== undefined) {
206+
child.keyLength = childKeyLength;
207+
}
199208

200209
this.currentParent.childrenKeys!.push(key);
201210
this.currentParent.childrenKey2Id![key] = child.id;
@@ -240,6 +249,7 @@ class Visitor {
240249
}
241250

242251
this.currentParent.childrenOffset![key] = offset;
252+
this.currentParent.childrenKeyLength![key] = key.length;
243253
}
244254

245255
onLiteralValue(value: any, offset: number, length: number, pathSupplier: () => jsonc.JSONPath) {

src/lib/parser/tree.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { RevealType } from "@/lib/graph/types";
12
import { getParentId, rootMarker } from "@/lib/idgen";
23
import { escape } from "@/lib/worker/command/escape";
34
import * as jsonc from "jsonc-parser";
@@ -124,7 +125,7 @@ export class Tree implements TreeObject {
124125
return this.isGraphNode(node) ? nodeId : getParentId(nodeId);
125126
}
126127

127-
findNodeAtOffset(offset: number): { node: Node; type: "node" | "key" | "value" } | undefined {
128+
findNodeAtOffset(offset: number): { node: Node; type: RevealType } | undefined {
128129
if (!this.valid()) {
129130
return undefined;
130131
}
@@ -168,7 +169,7 @@ export class Tree implements TreeObject {
168169
}
169170

170171
if (this.isGraphNode(node)) {
171-
return { node, type: "node" };
172+
return { node, type: "graphNode" };
172173
} else {
173174
return { node, type: node.offset < offset && offset <= node.offset + node.length ? "value" : "key" };
174175
}

0 commit comments

Comments
 (0)