Merge branch 'features/tree-view'
# Conflicts: # app/components/ColumnItem.tsx # app/components/PathBar.tsx
This commit is contained in:
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "typescript",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"option": "watch",
|
||||
"problemMatcher": ["$tsc-watch"],
|
||||
"group": "build",
|
||||
"label": "tsc: watch - tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export type ColumnItemProps = {
|
||||
json: unknown;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
selectedItem: (id: string) => void;
|
||||
onClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
function ColumnItemElement({
|
||||
@@ -18,7 +18,7 @@ function ColumnItemElement({
|
||||
json,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
selectedItem,
|
||||
onClick,
|
||||
}: ColumnItemProps) {
|
||||
const htmlElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -50,18 +50,31 @@ function ColumnItemElement({
|
||||
return (
|
||||
<div
|
||||
className={`flex h-9 items-center justify-items-stretch mx-1 px-1 py-1 my-1 rounded-sm ${stateStyle}`}
|
||||
onClick={() => selectedItem(item.id)}
|
||||
onClick={() => onClick && onClick(item.id)}
|
||||
ref={htmlElement}
|
||||
>
|
||||
<div className="w-4 flex-none flex-col justify-items-center">
|
||||
{item.icon && <item.icon className={`h-5 w-5 ${isSelected && isHighlighted ? "text-slate-900 dark:text-slate-300" : "text-slate-500"}`} />}
|
||||
|
||||
{item.icon && (
|
||||
<item.icon
|
||||
className={`h-5 w-5 ${
|
||||
isSelected && isHighlighted
|
||||
? "text-slate-900 dark:text-slate-300"
|
||||
: "text-slate-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-grow flex-shrink items-baseline justify-between truncate">
|
||||
<Body className="flex-grow flex-shrink-0 pl-3 pr-2 ">{item.title}</Body>
|
||||
{item.subtitle && (
|
||||
<Mono className={`truncate pr-1 transition duration-75 ${isHighlighted ? "text-slate-100" : "text-gray-400 dark:text-gray-500"}`}>
|
||||
<Mono
|
||||
className={`truncate pr-1 transition duration-75 ${
|
||||
isHighlighted
|
||||
? "text-slate-100"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Mono>
|
||||
)}
|
||||
|
||||
@@ -35,7 +35,7 @@ function ColumnsElement({ columns }: { columns: ColumnDefinition[] }) {
|
||||
isHighlighted={
|
||||
highlightedPath[highlightedPath.length - 1] === item.id
|
||||
}
|
||||
selectedItem={goToNodeId}
|
||||
onClick={(id) => goToNodeId(id, "columnView")}
|
||||
/>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
@@ -63,7 +63,7 @@ function HomeInfoBoxSectionContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const selectedPath = infoBoxData[index].highlight;
|
||||
api.goToNodeId(selectedPath);
|
||||
api.goToNodeId(selectedPath, "home");
|
||||
}, [index]);
|
||||
|
||||
const resetInterval = () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ export function JsonEditor() {
|
||||
|
||||
const path = JSONHeroPath.fromPointer(pointer);
|
||||
|
||||
goToNodeId(path.toString());
|
||||
goToNodeId(path.toString(), "editor");
|
||||
},
|
||||
[goToNodeId]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
useJsonColumnViewAPI,
|
||||
useJsonColumnViewState,
|
||||
} from "~/hooks/useJsonColumnView";
|
||||
import { JsonTreeViewNode, useJsonTreeViewContext } from "~/hooks/useJsonTree";
|
||||
import { VirtualNode } from "~/hooks/useVirtualTree";
|
||||
import { Body } from "./Primitives/Body";
|
||||
import { Mono } from "./Primitives/Mono";
|
||||
|
||||
export function JsonTreeView() {
|
||||
const { selectedNodeId, selectedNodeSource } = useJsonColumnViewState();
|
||||
const { goToNodeId } = useJsonColumnViewAPI();
|
||||
|
||||
const { tree, parentRef } = useJsonTreeViewContext();
|
||||
|
||||
// Scroll to the selected node when this component is first rendered.
|
||||
const scrolledToNodeRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrolledToNodeRef.current && selectedNodeId) {
|
||||
tree.scrollToNode(selectedNodeId);
|
||||
scrolledToNodeRef.current = true;
|
||||
}
|
||||
}, [selectedNodeId, scrolledToNodeRef]);
|
||||
|
||||
// This focuses and scrolls to the selected node when the selectedNodeId
|
||||
// is set from a source other than this tree (e.g. the search bar, path bar, related values).
|
||||
useEffect(() => {
|
||||
if (
|
||||
tree.focusedNodeId &&
|
||||
selectedNodeId &&
|
||||
tree.focusedNodeId !== selectedNodeId
|
||||
) {
|
||||
if (selectedNodeId === "$") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeSource !== "tree") {
|
||||
tree.focusNode(selectedNodeId);
|
||||
tree.scrollToNode(selectedNodeId);
|
||||
}
|
||||
}
|
||||
}, [tree.focusedNodeId, goToNodeId, selectedNodeId, selectedNodeSource]);
|
||||
|
||||
// This is what syncs the tree view's focused node to the column view selected node
|
||||
const previousFocusedNodeId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let updated = false;
|
||||
|
||||
if (!previousFocusedNodeId.current) {
|
||||
previousFocusedNodeId.current = tree.focusedNodeId;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (
|
||||
tree.focusedNodeId &&
|
||||
(updated || previousFocusedNodeId.current !== tree.focusedNodeId)
|
||||
) {
|
||||
previousFocusedNodeId.current = tree.focusedNodeId;
|
||||
goToNodeId(tree.focusedNodeId, "tree");
|
||||
}
|
||||
}, [previousFocusedNodeId, tree.focusedNodeId, tree.focusNode, goToNodeId]);
|
||||
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (treeRef.current) {
|
||||
treeRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [treeRef.current]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-white w-full"
|
||||
ref={parentRef}
|
||||
style={{
|
||||
height: `calc(100vh - 106px)`,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full outline-none"
|
||||
style={{ height: `${tree.totalSize}px` }}
|
||||
{...tree.getTreeProps()}
|
||||
ref={treeRef}
|
||||
>
|
||||
{tree.nodes.map((virtualNode) => (
|
||||
<TreeViewNode
|
||||
virtualNode={virtualNode}
|
||||
key={virtualNode.node.id}
|
||||
onToggle={(node, e) => tree.toggleNode(node.id, e)}
|
||||
selectedNodeId={selectedNodeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeViewNode({
|
||||
virtualNode,
|
||||
onToggle,
|
||||
selectedNodeId,
|
||||
}: {
|
||||
virtualNode: VirtualNode<JsonTreeViewNode>;
|
||||
selectedNodeId?: string;
|
||||
onToggle?: (node: JsonTreeViewNode, e: MouseEvent) => void;
|
||||
}) {
|
||||
const { node, virtualItem, depth } = virtualNode;
|
||||
|
||||
const indentClassName = computeTreeNodePaddingClass(depth);
|
||||
|
||||
const isSelected = selectedNodeId === node.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualNode.size}px`,
|
||||
transform: `translateY(${virtualNode.start}px)`,
|
||||
}}
|
||||
key={virtualNode.node.id}
|
||||
{...virtualNode.getItemProps()}
|
||||
>
|
||||
<div
|
||||
className={`h-full flex m-2 pl-5 rounded-sm select-none ${
|
||||
isSelected
|
||||
? "bg-indigo-700"
|
||||
: virtualItem.index % 2
|
||||
? "dark:bg-slate-900"
|
||||
: "bg-slate-200 bg-opacity-90 dark:bg-slate-800 dark:bg-opacity-30"
|
||||
}`}
|
||||
>
|
||||
<div className={`${indentClassName} w-2/6 items-center flex`}>
|
||||
{node.children && node.children.length > 0 && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
if (onToggle) {
|
||||
e.preventDefault();
|
||||
onToggle(node, e.nativeEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{virtualNode.isCollapsed ? (
|
||||
<ChevronRightIcon
|
||||
className={`w-4 h-4 mr-1 -ml-5 ${
|
||||
isSelected
|
||||
? "text-slate-100"
|
||||
: "text-slate-600 dark:text-slate-100"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
className={`w-4 h-4 mr-1 -ml-5 ${
|
||||
isSelected
|
||||
? "text-slate-100"
|
||||
: "text-slate-600 dark:text-slate-100"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Body
|
||||
className={`truncate whitespace-nowrap pr-2 ${
|
||||
isSelected
|
||||
? "text-slate-100"
|
||||
: "text-slate-700 dark:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{node.longTitle ?? node.name}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<div className="flex w-4/6 items-center">
|
||||
<span className="mr-2">
|
||||
{node.icon && (
|
||||
<node.icon
|
||||
className={`h-5 w-5 ${
|
||||
isSelected
|
||||
? "text-slate-100"
|
||||
: "text-slate-400 dark:text-slate-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{node.subtitle && (
|
||||
<Mono
|
||||
className={`truncate pr-1 transition ${
|
||||
isSelected
|
||||
? "text-slate-100"
|
||||
: "text-slate-500 dark:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{node.subtitle}
|
||||
</Mono>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeTreeNodePaddingClass(depth: number) {
|
||||
switch (depth) {
|
||||
case 0:
|
||||
return "pl-[4px]";
|
||||
case 1:
|
||||
return "pl-[calc(12px_+_4px)]";
|
||||
case 2:
|
||||
return "pl-[calc(12px_*_2_+_4px)]";
|
||||
case 3:
|
||||
return "pl-[calc(12px_*_3_+_4px)]";
|
||||
case 4:
|
||||
return "pl-[calc(12px_*_4_+_4px)]";
|
||||
case 5:
|
||||
return "pl-[calc(12px_*_5_+_4px)]";
|
||||
case 6:
|
||||
return "pl-[calc(12px_*_6_+_4px)]";
|
||||
case 7:
|
||||
return "pl-[calc(12px_*_7_+_4px)]";
|
||||
case 8:
|
||||
return "pl-[calc(12px_*_8_+_4px)]";
|
||||
case 9:
|
||||
return "pl-[calc(12px_*_9_+_4px)]";
|
||||
case 10:
|
||||
return "pl-[calc(12px_*_10_+_4px)]";
|
||||
default:
|
||||
return "pl-[calc(12px_*_11_+_4px)]";
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function PathBarLink({
|
||||
key={index}
|
||||
node={node}
|
||||
highlightedNodeId={highlightedNodeId}
|
||||
goToNodeId={goToNodeId}
|
||||
onClick={(id) => goToNodeId(id, "pathBar")}
|
||||
isLast={index == selectedNodes.length - 1}
|
||||
/>
|
||||
);
|
||||
@@ -94,12 +94,12 @@ export function PathHistoryControls() {
|
||||
function PathBarElement({
|
||||
node,
|
||||
highlightedNodeId,
|
||||
goToNodeId,
|
||||
onClick,
|
||||
isLast,
|
||||
}: {
|
||||
node: ColumnViewNode;
|
||||
highlightedNodeId: string | undefined;
|
||||
goToNodeId: (id: string) => void;
|
||||
onClick?: (id: string) => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -118,7 +118,7 @@ function PathBarElement({
|
||||
style={{
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onClick={() => goToNodeId(node.id)}
|
||||
onClick={() => onClick && onClick(node.id)}
|
||||
>
|
||||
<div className="w-4 flex-shrink-[0.5] flex-grow-0 flex-col justify-items-center whitespace-nowrap overflow-x-hidden transition dark:text-slate-400">
|
||||
{node.icon && <node.icon className="h-3 w-3" />}
|
||||
|
||||
@@ -81,7 +81,8 @@ export function PathPreview({
|
||||
: "disabled"
|
||||
}`}
|
||||
onClick={() =>
|
||||
isEnabled && goToNodeId(components[components.length - 1].id)
|
||||
isEnabled &&
|
||||
goToNodeId(components[components.length - 1].id, "relatedValues")
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -121,6 +121,7 @@ export function JsonColumnViewProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const isStateRestored = useRef<boolean>(!!initialNodeId);
|
||||
|
||||
// This is restoring the state
|
||||
useEffect(() => {
|
||||
if (isStateRestored.current) {
|
||||
return;
|
||||
@@ -134,9 +135,10 @@ export function JsonColumnViewProvider({ children }: { children: ReactNode }) {
|
||||
const restoredState = JSON.parse(storage) as ColumnViewInstanceState;
|
||||
if (!restoredState.selectedNodeId) return;
|
||||
|
||||
api.goToNodeId(restoredState.selectedNodeId);
|
||||
api.goToNodeId(restoredState.selectedNodeId, "localStorage");
|
||||
}, [doc.id, isStateRestored.current, state, api]);
|
||||
|
||||
// This is setting the state
|
||||
useEffect(() => {
|
||||
if (doc == null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useJson } from "./useJson";
|
||||
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import { IconComponent } from "~/useColumnView";
|
||||
import { formatValue } from "~/utilities/formatter";
|
||||
import { iconForType } from "~/utilities/jsonColumnView";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useVirtualTree, UseVirtualTreeInstance } from "./useVirtualTree";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useJsonDoc } from "./useJsonDoc";
|
||||
|
||||
const initialRect = { width: 800, height: 600 };
|
||||
|
||||
export type JsonTreeOptions = {
|
||||
overscan?: number;
|
||||
};
|
||||
|
||||
export type UseJsonTreeInstance = {
|
||||
tree: UseVirtualTreeInstance<JsonTreeViewNode>;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export type JsonTreeViewType = UseJsonTreeInstance;
|
||||
|
||||
const JsonTreeViewContext = createContext<JsonTreeViewType>(
|
||||
{} as JsonTreeViewType
|
||||
);
|
||||
|
||||
export function JsonTreeViewProvider({
|
||||
children,
|
||||
...options
|
||||
}: { children: ReactNode } & JsonTreeOptions) {
|
||||
const instance = useJsonTree(options);
|
||||
|
||||
return (
|
||||
<JsonTreeViewContext.Provider value={instance}>
|
||||
{children}
|
||||
</JsonTreeViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useJsonTree(options: JsonTreeOptions): UseJsonTreeInstance {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { doc } = useJsonDoc();
|
||||
const [json] = useJson();
|
||||
const jsonNodes = useMemo(() => {
|
||||
return generateTreeViewNodes(json);
|
||||
}, [json]);
|
||||
|
||||
const tree = useVirtualTree({
|
||||
id: doc.id,
|
||||
nodes: jsonNodes,
|
||||
parentRef,
|
||||
estimateSize: useCallback((index) => 32, []),
|
||||
initialRect,
|
||||
overscan: options.overscan,
|
||||
persistState: true,
|
||||
});
|
||||
|
||||
return { tree, parentRef };
|
||||
}
|
||||
|
||||
export function useJsonTreeViewContext(): JsonTreeViewType {
|
||||
const context = useContext(JsonTreeViewContext);
|
||||
|
||||
invariant(
|
||||
context,
|
||||
"useJsonTreeViewContext must be used within a JsonTreeViewContext.Provider"
|
||||
);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export type JsonTreeViewNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
longTitle?: string;
|
||||
icon?: IconComponent;
|
||||
children?: Array<JsonTreeViewNode>;
|
||||
};
|
||||
|
||||
export function generateTreeViewNodes(json: unknown): Array<JsonTreeViewNode> {
|
||||
const info = inferType(json);
|
||||
const path = new JSONHeroPath("$");
|
||||
|
||||
return generateChildren(info, path) ?? [];
|
||||
}
|
||||
|
||||
function generateChildren(
|
||||
info: JSONValueType,
|
||||
path: JSONHeroPath
|
||||
): Array<JsonTreeViewNode> | undefined {
|
||||
if (info.name === "array") {
|
||||
return info.value.map((item, index) => {
|
||||
const itemInfo = inferType(item);
|
||||
const itemPath = path.child(index.toString());
|
||||
|
||||
return {
|
||||
id: itemPath.toString(),
|
||||
name: index.toString(),
|
||||
title: index.toString(),
|
||||
longTitle: `Index ${index.toString()}`,
|
||||
subtitle: formatValue(itemInfo),
|
||||
icon: iconForType(itemInfo),
|
||||
children: generateChildren(itemInfo, itemPath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (info.name === "object") {
|
||||
return Object.entries(info.value).map(([key, value]) => {
|
||||
const itemInfo = inferType(value);
|
||||
const itemPath = path.child(key);
|
||||
return {
|
||||
id: itemPath.toString(),
|
||||
name: key,
|
||||
title: key,
|
||||
subtitle: formatValue(itemInfo),
|
||||
icon: iconForType(itemInfo),
|
||||
children: generateChildren(itemInfo, itemPath),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import pick from "lodash-es/pick";
|
||||
import React, {
|
||||
useReducer,
|
||||
Reducer,
|
||||
useCallback,
|
||||
Dispatch,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useVirtual, VirtualItem } from "react-virtual";
|
||||
|
||||
type UseVirtualOptions<R> = Parameters<typeof useVirtual>[0];
|
||||
|
||||
export type UseVirtualTreeOptions<
|
||||
T extends { id: string; children?: T[] },
|
||||
R
|
||||
> = {
|
||||
id: string;
|
||||
persistState?: boolean;
|
||||
nodes: T[];
|
||||
} & Omit<UseVirtualOptions<R>, "size">;
|
||||
|
||||
export type VirtualNode<T> = {
|
||||
node: T;
|
||||
size: number; // This is the same as virtualItem.size
|
||||
start: number; // This is the same as virtualItem.start
|
||||
virtualItem: VirtualItem;
|
||||
depth: number;
|
||||
getItemProps: () => React.HTMLAttributes<HTMLElement>;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
export type UseVirtualTreeInstance<T> = {
|
||||
nodes: VirtualNode<T>[];
|
||||
focusedNodeId: string | null;
|
||||
totalSize: number;
|
||||
toggleNode: (id: string, source?: KeyboardEvent | MouseEvent) => void;
|
||||
focusNode: (id: string) => void;
|
||||
focusFirst: () => void;
|
||||
blur: () => void;
|
||||
scrollToNode: (id: string) => void;
|
||||
getTreeProps: () => React.HTMLAttributes<HTMLElement>;
|
||||
};
|
||||
|
||||
type TreeNodeItem<T extends { id: string; children?: T[] }> = {
|
||||
id: string;
|
||||
depth: number;
|
||||
node: T;
|
||||
pos: number;
|
||||
size: number;
|
||||
isCollapsed: boolean;
|
||||
};
|
||||
|
||||
type TreeState<T extends { id: string; children?: T[] }> = {
|
||||
nodes: T[];
|
||||
items: TreeNodeItem<T>[];
|
||||
collapsedState: Record<string, boolean>;
|
||||
focusedNodeId: string | null;
|
||||
};
|
||||
|
||||
type ToggleNodeAction = {
|
||||
type: "TOGGLE_NODE";
|
||||
id: string;
|
||||
source?: KeyboardEvent | MouseEvent;
|
||||
};
|
||||
|
||||
type FocusNodeAction = {
|
||||
type: "FOCUS_NODE";
|
||||
id: string;
|
||||
};
|
||||
|
||||
type MoveNodeAction = {
|
||||
type: "MOVE_DOWN" | "MOVE_UP" | "MOVE_TO_TOP" | "MOVE_TO_BOTTOM";
|
||||
source: KeyboardEvent | MouseEvent;
|
||||
};
|
||||
|
||||
type MoveRightAction = {
|
||||
type: "MOVE_RIGHT";
|
||||
source: KeyboardEvent | MouseEvent;
|
||||
};
|
||||
|
||||
type MoveLeftAction = {
|
||||
type: "MOVE_LEFT";
|
||||
source: KeyboardEvent | MouseEvent;
|
||||
};
|
||||
|
||||
type FocusFirstAction = {
|
||||
type: "FOCUS_FIRST";
|
||||
};
|
||||
|
||||
type RestoreStateAction = {
|
||||
type: "RESTORE_STATE";
|
||||
restoredState: { collapsedState: Record<string, boolean> };
|
||||
};
|
||||
|
||||
type ExpandAllOnPathAction = {
|
||||
type: "EXPAND_ALL_ON_PATH";
|
||||
path: string[];
|
||||
};
|
||||
|
||||
type BlurAction = {
|
||||
type: "BLUR";
|
||||
};
|
||||
|
||||
type TreeAction =
|
||||
| ToggleNodeAction
|
||||
| MoveNodeAction
|
||||
| FocusNodeAction
|
||||
| FocusFirstAction
|
||||
| MoveRightAction
|
||||
| MoveLeftAction
|
||||
| RestoreStateAction
|
||||
| ExpandAllOnPathAction
|
||||
| BlurAction;
|
||||
|
||||
function expandNode<T extends { id: string; children?: T[] }>(
|
||||
state: TreeState<T>,
|
||||
id: string
|
||||
): TreeState<T> {
|
||||
const collapsedState = {
|
||||
...state.collapsedState,
|
||||
[id]: false,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
collapsedState,
|
||||
items: createNodeItems(state.nodes, 0, collapsedState),
|
||||
focusedNodeId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function collapseNode<T extends { id: string; children?: T[] }>(
|
||||
state: TreeState<T>,
|
||||
id: string
|
||||
): TreeState<T> {
|
||||
const collapsedState = {
|
||||
...state.collapsedState,
|
||||
[id]: true,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
collapsedState,
|
||||
items: createNodeItems(state.nodes, 0, collapsedState),
|
||||
focusedNodeId: id,
|
||||
};
|
||||
}
|
||||
function toggleAllChildren<T extends { id: string; children?: T[] }>(
|
||||
state: TreeState<T>,
|
||||
id: string
|
||||
): TreeState<T> {
|
||||
const item = state.items.find(({ id: nodeId }) => nodeId === id);
|
||||
|
||||
if (!item) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!item.node.children || item.node.children.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const allCollapsed = item.node.children.every(
|
||||
(child) => state.collapsedState[child.id]
|
||||
);
|
||||
|
||||
if (allCollapsed) {
|
||||
const collapsedState = item.node.children.reduce(
|
||||
(acc, child) => ({
|
||||
...acc,
|
||||
[child.id]: false,
|
||||
}),
|
||||
state.collapsedState
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
collapsedState,
|
||||
items: createNodeItems(state.nodes, 0, collapsedState),
|
||||
focusedNodeId: id,
|
||||
};
|
||||
}
|
||||
|
||||
const collapsedState = item.node.children.reduce(
|
||||
(acc, child) => ({
|
||||
...acc,
|
||||
[child.id]: true,
|
||||
}),
|
||||
state.collapsedState
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
collapsedState,
|
||||
items: createNodeItems(state.nodes, 0, collapsedState),
|
||||
focusedNodeId: id,
|
||||
};
|
||||
}
|
||||
|
||||
export function useVirtualTree<T extends { id: string; children?: T[] }, R>(
|
||||
options: UseVirtualTreeOptions<T, R>
|
||||
): UseVirtualTreeInstance<T> {
|
||||
const reducer = useCallback<Reducer<TreeState<T>, TreeAction>>(
|
||||
(state, action) => {
|
||||
switch (action.type) {
|
||||
case "BLUR": {
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: null,
|
||||
};
|
||||
}
|
||||
case "TOGGLE_NODE": {
|
||||
const isCollapsed = state.collapsedState[action.id];
|
||||
|
||||
if (isCollapsed) {
|
||||
return expandNode<T>(state, action.id);
|
||||
} else {
|
||||
if (
|
||||
action.source &&
|
||||
(action.source.shiftKey || action.source.altKey)
|
||||
) {
|
||||
return toggleAllChildren<T>(state, action.id);
|
||||
} else {
|
||||
return collapseNode<T>(state, action.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "FOCUS_NODE": {
|
||||
const itemIndex = state.items.findIndex(({ id }) => id === action.id);
|
||||
|
||||
if (itemIndex === -1) {
|
||||
const node = findNodeInTreeById(state.nodes, action.id);
|
||||
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const path = calculatePathToNode(state.nodes, node) ?? [];
|
||||
|
||||
const collapsedState = path.reduce(
|
||||
(acc, id) => ({
|
||||
...acc,
|
||||
[id]: false,
|
||||
}),
|
||||
state.collapsedState
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
collapsedState,
|
||||
items: createNodeItems(state.nodes, 0, collapsedState),
|
||||
focusedNodeId: action.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: action.id,
|
||||
};
|
||||
}
|
||||
case "FOCUS_FIRST":
|
||||
case "MOVE_TO_TOP": {
|
||||
const nextItem = state.items[0];
|
||||
|
||||
if (!nextItem) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
case "MOVE_TO_BOTTOM": {
|
||||
const nextItem = state.items[state.items.length - 1];
|
||||
|
||||
if (!nextItem) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
case "MOVE_DOWN": {
|
||||
if (!state.focusedNodeId) {
|
||||
const nextItem = state.items[0];
|
||||
|
||||
if (!nextItem) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedNodeIdIndex = state.items.findIndex(
|
||||
(item) => item.id === state.focusedNodeId
|
||||
);
|
||||
|
||||
if (focusedNodeIdIndex === -1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.items.length <= focusedNodeIdIndex + 1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextItem = state.items[focusedNodeIdIndex + 1];
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
case "MOVE_UP": {
|
||||
const focusedNodeIdIndex = state.items.findIndex(
|
||||
(item) => item.id === state.focusedNodeId
|
||||
);
|
||||
|
||||
if (focusedNodeIdIndex === -1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (focusedNodeIdIndex === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextItem = state.items[focusedNodeIdIndex - 1];
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
case "MOVE_RIGHT": {
|
||||
if (!state.focusedNodeId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const isCollapsed = state.collapsedState[state.focusedNodeId];
|
||||
|
||||
if (isCollapsed) {
|
||||
return expandNode<T>(state, state.focusedNodeId);
|
||||
}
|
||||
|
||||
if (
|
||||
action.source &&
|
||||
(action.source.shiftKey || action.source.altKey)
|
||||
) {
|
||||
return toggleAllChildren<T>(state, state.focusedNodeId);
|
||||
}
|
||||
|
||||
const nodeIndex = state.items.findIndex(
|
||||
(item) => item.id === state.focusedNodeId
|
||||
);
|
||||
|
||||
if (nodeIndex === -1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.items.length <= nodeIndex + 1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextItem = state.items[nodeIndex + 1];
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
case "MOVE_LEFT": {
|
||||
if (!state.focusedNodeId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const item = state.items.find(
|
||||
(item) => item.id === state.focusedNodeId
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const hasChildren =
|
||||
item.node.children && item.node.children.length > 0;
|
||||
const isCollapsed = state.collapsedState[state.focusedNodeId];
|
||||
|
||||
if (hasChildren && !isCollapsed) {
|
||||
if (
|
||||
action.source &&
|
||||
(action.source.shiftKey || action.source.altKey)
|
||||
) {
|
||||
return toggleAllChildren<T>(state, state.focusedNodeId);
|
||||
} else {
|
||||
return collapseNode<T>(state, state.focusedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChildren || isCollapsed) {
|
||||
// Try to go to the parent node
|
||||
const parentNodeIndex = state.items.findIndex(
|
||||
(item) =>
|
||||
item.node.children &&
|
||||
item.node.children
|
||||
.map((child) => child.id)
|
||||
.includes(state.focusedNodeId!)
|
||||
);
|
||||
|
||||
if (parentNodeIndex === -1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextItem = state.items[parentNodeIndex];
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedNodeId: nextItem.id,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "RESTORE_STATE": {
|
||||
const nextState = {
|
||||
...state,
|
||||
...action.restoredState,
|
||||
};
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
items: createNodeItems(
|
||||
nextState.nodes,
|
||||
0,
|
||||
nextState.collapsedState
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const initializer = useCallback(
|
||||
({ nodes }: { nodes: T[] }) => {
|
||||
return {
|
||||
nodes,
|
||||
items: createNodeItems(nodes),
|
||||
collapsedState: {},
|
||||
focusedNodeId: null,
|
||||
};
|
||||
},
|
||||
[options.persistState, options.id]
|
||||
);
|
||||
|
||||
const [state, dispatch] = useReducer<
|
||||
Reducer<TreeState<T>, TreeAction>,
|
||||
{ nodes: T[] }
|
||||
>(
|
||||
reducer,
|
||||
{
|
||||
nodes: options.nodes,
|
||||
},
|
||||
initializer
|
||||
);
|
||||
|
||||
const isStateRestored = useRef<boolean>(false);
|
||||
|
||||
// This is setting the state
|
||||
useEffect(() => {
|
||||
if (!isStateRestored.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.persistState) {
|
||||
localStorage.setItem(
|
||||
`${options.id}-virtual-tree-state`,
|
||||
JSON.stringify(pick(state, "collapsedState"))
|
||||
);
|
||||
}
|
||||
}, [
|
||||
state.collapsedState,
|
||||
options.id,
|
||||
options.persistState,
|
||||
isStateRestored.current,
|
||||
]);
|
||||
|
||||
// This is restoring the state
|
||||
useEffect(() => {
|
||||
if (!options.persistState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStateRestored.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isStateRestored.current = true;
|
||||
|
||||
const savedState = localStorage.getItem(`${options.id}-virtual-tree-state`);
|
||||
|
||||
if (savedState) {
|
||||
const restoredState = JSON.parse(savedState) as {
|
||||
collapsedState: Record<string, boolean>;
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "RESTORE_STATE",
|
||||
restoredState,
|
||||
});
|
||||
}
|
||||
}, [options.persistState, options.id, dispatch, isStateRestored.current]);
|
||||
|
||||
const rowVirtualizer = useVirtual({
|
||||
size: state.items.length,
|
||||
parentRef: options.parentRef,
|
||||
estimateSize: options.estimateSize,
|
||||
overscan: options.overscan,
|
||||
initialRect: options.initialRect,
|
||||
useObserver: options.useObserver,
|
||||
});
|
||||
|
||||
const allVirtualNodes = rowVirtualizer.virtualItems.map((virtualItem) => {
|
||||
const treeItem = state.items[virtualItem.index];
|
||||
|
||||
return {
|
||||
node: treeItem.node,
|
||||
depth: treeItem.depth,
|
||||
size: virtualItem.size,
|
||||
start: virtualItem.start,
|
||||
virtualItem,
|
||||
getItemProps: createItemProps(treeItem, virtualItem, state, dispatch),
|
||||
isCollapsed: treeItem.isCollapsed,
|
||||
};
|
||||
});
|
||||
|
||||
const toggleNode = useCallback(
|
||||
(id: string, source?: KeyboardEvent | MouseEvent) => {
|
||||
dispatch({ type: "TOGGLE_NODE", id, source });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const focusNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "FOCUS_NODE", id });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const focusFirst = useCallback(
|
||||
() => dispatch({ type: "FOCUS_FIRST" }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const blur = useCallback(() => dispatch({ type: "BLUR" }), [dispatch]);
|
||||
|
||||
// TODO: have this work with collapsed nodes
|
||||
const scrollToNode = useCallback(
|
||||
(id: string) => {
|
||||
const itemIndex = state.items.findIndex((item) => item.id === id);
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
rowVirtualizer.scrollToIndex(itemIndex, { align: "auto" });
|
||||
}
|
||||
},
|
||||
[state.items, rowVirtualizer.scrollToIndex, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.focusedNodeId) {
|
||||
scrollToNode(state.focusedNodeId);
|
||||
}
|
||||
}, [state.focusedNodeId, scrollToNode]);
|
||||
|
||||
return {
|
||||
nodes: allVirtualNodes,
|
||||
totalSize: rowVirtualizer.totalSize,
|
||||
toggleNode,
|
||||
focusNode,
|
||||
focusFirst,
|
||||
blur,
|
||||
focusedNodeId: state.focusedNodeId,
|
||||
getTreeProps: useCallback(createTreeProps(dispatch), [dispatch]),
|
||||
scrollToNode,
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeItems<T extends { id: string; children?: T[] }>(
|
||||
nodes: T[],
|
||||
depth = 0,
|
||||
collapsedState: Record<string, boolean> = {}
|
||||
): TreeNodeItem<T>[] {
|
||||
return nodes.flatMap((node, index) => {
|
||||
const children = node.children
|
||||
? collapsedState[node.id]
|
||||
? []
|
||||
: createNodeItems(node.children, depth + 1, collapsedState)
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
depth,
|
||||
node,
|
||||
pos: index + 1,
|
||||
size: nodes.length,
|
||||
isCollapsed: !!collapsedState[node.id],
|
||||
},
|
||||
...children,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function createTreeProps<T extends { id: string; children?: T[] }>(
|
||||
dispatch: Dispatch<TreeAction>
|
||||
): () => React.HTMLAttributes<HTMLElement> {
|
||||
return () => ({
|
||||
role: "tree",
|
||||
tabIndex: -1,
|
||||
onKeyDown: (e) => {
|
||||
if (e.defaultPrevented) {
|
||||
return; // Do nothing if the event was already processed
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Home": {
|
||||
dispatch({ type: "MOVE_TO_TOP", source: e.nativeEvent });
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "End": {
|
||||
dispatch({ type: "MOVE_TO_BOTTOM", source: e.nativeEvent });
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Down":
|
||||
case "ArrowDown": {
|
||||
dispatch({ type: "MOVE_DOWN", source: e.nativeEvent });
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Up":
|
||||
case "ArrowUp": {
|
||||
dispatch({ type: "MOVE_UP", source: e.nativeEvent });
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
dispatch({
|
||||
type: "MOVE_LEFT",
|
||||
source: e.nativeEvent,
|
||||
});
|
||||
e.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
case "Right":
|
||||
case "ArrowRight": {
|
||||
dispatch({
|
||||
type: "MOVE_RIGHT",
|
||||
source: e.nativeEvent,
|
||||
});
|
||||
e.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createItemProps<T extends { id: string; children?: T[] }>(
|
||||
item: TreeNodeItem<T>,
|
||||
virtualItem: VirtualItem,
|
||||
state: TreeState<T>,
|
||||
dispatch: Dispatch<TreeAction>
|
||||
): () => React.HTMLAttributes<HTMLElement> {
|
||||
const { depth, pos, size, node, isCollapsed } = item;
|
||||
|
||||
return () => ({
|
||||
"aria-expanded": node.children && node.children.length > 0 && !isCollapsed,
|
||||
"aria-level": depth + 1,
|
||||
"aria-posinset": pos,
|
||||
"aria-setsize": size,
|
||||
role: "treeitem",
|
||||
tabIndex: node.id === state.focusedNodeId ? -1 : undefined,
|
||||
onClick: (e) => {
|
||||
if (e.defaultPrevented) {
|
||||
return; // Do nothing if the event was already processed
|
||||
}
|
||||
|
||||
if (node.id !== state.focusedNodeId) {
|
||||
dispatch({ type: "FOCUS_NODE", id: node.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Finds the node in the list of nodes or recursively in the children
|
||||
function findNodeInTreeById<T extends { id: string; children?: T[] }>(
|
||||
nodes: T[],
|
||||
id: string
|
||||
): T | undefined {
|
||||
const node = nodes.find((node) => node.id === id);
|
||||
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const foundNode = findNodeInTreeById(node.children || [], id);
|
||||
|
||||
if (foundNode) {
|
||||
return foundNode;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function calculatePathToNode<T extends { id: string; children?: T[] }>(
|
||||
nodes: T[],
|
||||
searchNode: T,
|
||||
path: string[] = []
|
||||
): string[] | undefined {
|
||||
const nodeIndex = nodes.findIndex((node) => node.id === searchNode.id);
|
||||
|
||||
if (nodeIndex !== -1) {
|
||||
return [...path, searchNode.id];
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!node.children) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const foundPath = calculatePathToNode(node.children || [], searchNode, [
|
||||
...path,
|
||||
node.id,
|
||||
]);
|
||||
|
||||
if (foundPath && foundPath.length > path.length) {
|
||||
return foundPath;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
+25
-22
@@ -20,6 +20,7 @@ import { JsonColumnViewProvider } from "~/hooks/useJsonColumnView";
|
||||
import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
|
||||
import { JsonView } from "~/components/JsonView";
|
||||
import safeFetch from "~/utilities/safeFetch";
|
||||
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
|
||||
|
||||
export const loader: LoaderFunction = async ({ params, request }) => {
|
||||
invariant(params.id, "expected params.id");
|
||||
@@ -113,32 +114,34 @@ export default function JsonDocumentRoute() {
|
||||
<JsonProvider initialJson={loaderData.json}>
|
||||
<JsonSchemaProvider>
|
||||
<JsonColumnViewProvider>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
<JsonTreeViewProvider overscan={25}>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer></Footer>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</JsonTreeViewProvider>
|
||||
</JsonColumnViewProvider>
|
||||
</JsonSchemaProvider>
|
||||
</JsonProvider>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Title } from "~/components/Primitives/Title";
|
||||
|
||||
export default function Terminal() {
|
||||
return (
|
||||
<div className="relative block w-6 ml-6">
|
||||
<Title className="mt-2 block text-sm font-medium text-gray-900 dark:text-white">
|
||||
Terminal View coming soon
|
||||
</Title>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { LargeTitle } from "~/components/Primitives/LargeTitle";
|
||||
import { Body } from "~/components/Primitives/Body";
|
||||
import { TerminalIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function Terminal() {
|
||||
export default function TerminalViewPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center max-w-[300px] rounded text-center bg-slate-200 shadow border-slate-100 border-[10px] border-solid py-16 px-16 transition dark:bg-slate-700 dark:border-slate-500">
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import { TreeIcon } from "~/components/Icons/TreeIcon";
|
||||
import { LargeTitle } from "~/components/Primitives/LargeTitle";
|
||||
import { Body } from "~/components/Primitives/Body";
|
||||
import { JsonTreeView } from "~/components/JsonTreeView";
|
||||
|
||||
export default function Terminal() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center max-w-[300px] rounded text-center bg-slate-200 shadow border-slate-100 border-[10px] border-solid py-16 px-16 transition dark:bg-slate-700 dark:border-slate-500">
|
||||
<TreeIcon className="text-indigo-500 transition dark:text-white w-8 mb-2" />
|
||||
<LargeTitle className="text-gray-900 transition dark:text-white">
|
||||
Tree View
|
||||
</LargeTitle>
|
||||
<Body className="text-gray-700 transition dark:text-white">
|
||||
Coming soon
|
||||
</Body>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function TreeViewPage() {
|
||||
return <JsonTreeView />;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type ColumnViewInstanceState = {
|
||||
columns: Array<ColumnDefinition>;
|
||||
getColumnViewProps: () => ColumnViewProps;
|
||||
selectedNodeId?: string;
|
||||
selectedNodeSource?: string;
|
||||
selectedPath: string[];
|
||||
highlightedNodeId?: string;
|
||||
highlightedPath: string[];
|
||||
@@ -55,7 +56,7 @@ export type ColumnViewAPIOptions = {
|
||||
export type ColumnViewAPI = {
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
goToNodeId: (nodeId: string) => void;
|
||||
goToNodeId: (nodeId: string, source: string) => void;
|
||||
goToParent: (options?: ColumnViewAPIOptions) => void;
|
||||
goToChildren: () => void;
|
||||
goToNextSibling: () => void;
|
||||
@@ -158,8 +159,8 @@ export function useColumnView({
|
||||
goForward: () => {
|
||||
dispatch(goForwardAction());
|
||||
},
|
||||
goToNodeId: (nodeId: string) => {
|
||||
dispatch(goToNodeIdAction(nodeId));
|
||||
goToNodeId: (nodeId: string, source: string) => {
|
||||
dispatch(goToNodeIdAction(nodeId, source));
|
||||
},
|
||||
goToParent: (options?: ColumnViewAPIOptions) => {
|
||||
dispatch(goToParentAction(options));
|
||||
@@ -177,10 +178,15 @@ export function useColumnView({
|
||||
dispatch(resetSelectionAction());
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const { selectedNodeId, highlightedNodeId, history, historyCurrentIndex } =
|
||||
state;
|
||||
const {
|
||||
selectedNodeId,
|
||||
highlightedNodeId,
|
||||
selectedNodeSource,
|
||||
history,
|
||||
historyCurrentIndex,
|
||||
} = state;
|
||||
|
||||
const selectedPath = getPathToNode(nodeTable, selectedNodeId);
|
||||
const highlightedPath = getPathToNode(nodeTable, highlightedNodeId);
|
||||
@@ -209,6 +215,7 @@ export function useColumnView({
|
||||
return {
|
||||
state: {
|
||||
selectedNodeId,
|
||||
selectedNodeSource,
|
||||
selectedPath,
|
||||
selectedNodes,
|
||||
highlightedNodeId,
|
||||
@@ -225,6 +232,7 @@ export function useColumnView({
|
||||
export type ColumnViewState = {
|
||||
selectedNodeId?: string;
|
||||
highlightedNodeId?: string;
|
||||
selectedNodeSource?: string;
|
||||
history: Array<Omit<ColumnViewState, "history" | "historyCurrentIndex">>;
|
||||
historyCurrentIndex: number;
|
||||
nodeTable: NodeTable;
|
||||
@@ -234,6 +242,7 @@ export type ColumnViewState = {
|
||||
export type SetSelectedNodeIdAction = {
|
||||
type: "SET_SELECTED_NODE_ID";
|
||||
id: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type MoveSelectedNodeAction = {
|
||||
@@ -276,10 +285,14 @@ function resetSelectionAction(): ResetSelectionNodeAction {
|
||||
};
|
||||
}
|
||||
|
||||
function goToNodeIdAction(nodeId: string): SetSelectedNodeIdAction {
|
||||
function goToNodeIdAction(
|
||||
nodeId: string,
|
||||
source: string
|
||||
): SetSelectedNodeIdAction {
|
||||
return {
|
||||
type: "SET_SELECTED_NODE_ID",
|
||||
id: nodeId,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,6 +352,7 @@ function columnViewReducer(
|
||||
...state,
|
||||
selectedNodeId: action.id,
|
||||
highlightedNodeId: action.id,
|
||||
selectedNodeSource: action.source,
|
||||
};
|
||||
case "MOVE_DOWN": {
|
||||
if (state.highlightedNodeId === state.rootNodeId) {
|
||||
|
||||
@@ -31,15 +31,19 @@ export function formatValue(type: JSONValueType): string | undefined {
|
||||
case "array": {
|
||||
if (type.value.length == 0) {
|
||||
return formatRawValue(type);
|
||||
} else if (type.value.length === 1) {
|
||||
return `1 item`;
|
||||
} else {
|
||||
return undefined;
|
||||
return `${type.value.length} items`;
|
||||
}
|
||||
}
|
||||
case "object": {
|
||||
if (Object.keys(type.value).length == 0) {
|
||||
return formatRawValue(type);
|
||||
} else if (Object.keys(type.value).length === 1) {
|
||||
return `1 field`;
|
||||
} else {
|
||||
return undefined;
|
||||
return `${Object.keys(type.value).length} fields`;
|
||||
}
|
||||
}
|
||||
case "bool": {
|
||||
|
||||
@@ -119,7 +119,7 @@ export function generateNodesToPath(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function iconForType(type: JSONValueType): IconComponent {
|
||||
export function iconForType(type: JSONValueType): IconComponent {
|
||||
switch (type.name) {
|
||||
case "object": {
|
||||
return CubeIcon;
|
||||
|
||||
Generated
+33
-18
@@ -34,6 +34,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.4.2",
|
||||
"react-hotkeys-hook": "^3.4.4",
|
||||
"react-virtual": "^2.10.4",
|
||||
"remix": "^1.2.3",
|
||||
"tailwindcss-radix": "^1.6.0",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
@@ -41,8 +42,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.2.2",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
|
||||
"@remix-run/dev": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@types/color": "^3.0.3",
|
||||
@@ -924,15 +923,6 @@
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@esbuild-plugins/node-globals-polyfill": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
|
||||
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"esbuild": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild-plugins/node-modules-polyfill": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
|
||||
@@ -2436,6 +2426,11 @@
|
||||
"@babel/runtime": "^7.13.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@reach/observe-rect": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
|
||||
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
|
||||
},
|
||||
"node_modules/@remix-run/cloudflare-workers": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/cloudflare-workers/-/cloudflare-workers-1.2.3.tgz",
|
||||
@@ -11408,6 +11403,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtual": {
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
|
||||
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/tannerlinsley"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reach/observe-rect": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.6.3 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-package-json-fast": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz",
|
||||
@@ -14173,13 +14182,6 @@
|
||||
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild-plugins/node-globals-polyfill": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz",
|
||||
"integrity": "sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@esbuild-plugins/node-modules-polyfill": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz",
|
||||
@@ -15333,6 +15335,11 @@
|
||||
"@babel/runtime": "^7.13.10"
|
||||
}
|
||||
},
|
||||
"@reach/observe-rect": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
|
||||
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ=="
|
||||
},
|
||||
"@remix-run/cloudflare-workers": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/cloudflare-workers/-/cloudflare-workers-1.2.3.tgz",
|
||||
@@ -21869,6 +21876,14 @@
|
||||
"tslib": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"react-virtual": {
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
|
||||
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
|
||||
"requires": {
|
||||
"@reach/observe-rect": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"read-package-json-fast": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-2.0.3.tgz",
|
||||
|
||||
+1
-2
@@ -51,6 +51,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.4.2",
|
||||
"react-hotkeys-hook": "^3.4.4",
|
||||
"react-virtual": "^2.10.4",
|
||||
"remix": "^1.2.3",
|
||||
"tailwindcss-radix": "^1.6.0",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
@@ -58,8 +59,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.2.2",
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
|
||||
"@remix-run/dev": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@types/color": "^3.0.3",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"skipDefaultLibCheck": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
// Remix takes care of building everything in `remix build`.
|
||||
"noEmit": true
|
||||
|
||||
Reference in New Issue
Block a user