diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..68a8953 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} diff --git a/app/components/ColumnItem.tsx b/app/components/ColumnItem.tsx index 019401b..0e16a7e 100644 --- a/app/components/ColumnItem.tsx +++ b/app/components/ColumnItem.tsx @@ -54,7 +54,7 @@ export function ColumnItem({ item }: { item: ColumnViewNode }) { return (
goToNodeId(id)} + onClick={() => goToNodeId(id, "columnView")} ref={htmlElement} >
diff --git a/app/components/Home/HomeInfoBoxSection.tsx b/app/components/Home/HomeInfoBoxSection.tsx index 813babc..0d0fc51 100644 --- a/app/components/Home/HomeInfoBoxSection.tsx +++ b/app/components/Home/HomeInfoBoxSection.tsx @@ -63,7 +63,7 @@ function HomeInfoBoxSectionContent() { useEffect(() => { const selectedPath = infoBoxData[index].highlight; - api.goToNodeId(selectedPath); + api.goToNodeId(selectedPath, "home"); }, [index]); const resetInterval = () => { diff --git a/app/components/JsonEditor.tsx b/app/components/JsonEditor.tsx index b4aaee8..8b6c4db 100644 --- a/app/components/JsonEditor.tsx +++ b/app/components/JsonEditor.tsx @@ -72,7 +72,7 @@ export function JsonEditor() { const path = JSONHeroPath.fromPointer(pointer); - goToNodeId(path.toString()); + goToNodeId(path.toString(), "editor"); }, [goToNodeId] ); diff --git a/app/components/JsonTreeView.tsx b/app/components/JsonTreeView.tsx index 276c240..f787756 100644 --- a/app/components/JsonTreeView.tsx +++ b/app/components/JsonTreeView.tsx @@ -10,11 +10,12 @@ import { Body } from "./Primitives/Body"; import { Mono } from "./Primitives/Mono"; export function JsonTreeView() { - const { selectedNodeId } = useJsonColumnViewState(); + 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(() => { @@ -24,6 +25,43 @@ export function JsonTreeView() { } }, [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 (selectedNodeSource !== "tree") { + if (selectedNodeId === "$") { + tree.focusFirst(); + } else { + 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(null); + + useEffect(() => { + if (!previousFocusedNodeId.current) { + previousFocusedNodeId.current = tree.focusedNodeId; + return; + } + + if ( + tree.focusedNodeId && + previousFocusedNodeId.current !== tree.focusedNodeId + ) { + previousFocusedNodeId.current = tree.focusedNodeId; + goToNodeId(tree.focusedNodeId, "tree"); + } + }, [previousFocusedNodeId, tree.focusedNodeId, tree.focusNode, goToNodeId]); + return (
tree.toggleNode(virtualNode.node.id)} - onClick={() => goToNodeId(virtualNode.node.id)} selectedNodeId={selectedNodeId} /> ))} @@ -55,20 +92,29 @@ export function JsonTreeView() { function TreeViewNode({ virtualNode, - onClick, onToggle, selectedNodeId, }: { virtualNode: VirtualNode; selectedNodeId?: string; - onClick?: (node: JsonTreeViewNode) => void; onToggle?: (node: JsonTreeViewNode) => void; }) { + const { tree } = useJsonTreeViewContext(); + const { node, virtualItem, depth } = virtualNode; const indentClassName = computeTreeNodePaddingClass(depth); const isSelected = selectedNodeId === node.id; + const isFocused = tree.focusedNodeId === node.id; + + const elementRef = useRef(null); + + useEffect(() => { + if (elementRef.current && isFocused) { + elementRef.current.focus(); + } + }, [elementRef, isFocused]); return (
{ - if (onClick) { - e.stopPropagation(); - onClick(virtualNode.node); - } - }} + ref={elementRef} >
{ if (onToggle) { - e.stopPropagation(); + e.preventDefault(); onToggle(node); } }} diff --git a/app/components/PathBar.tsx b/app/components/PathBar.tsx index 8437649..8310ba1 100644 --- a/app/components/PathBar.tsx +++ b/app/components/PathBar.tsx @@ -49,7 +49,7 @@ export function PathBarLink({ : "hover:bg-slate-300 px-2 py-[3px] rounded-sm transition dark:hover:bg-white dark:hover:bg-opacity-[5%]" }`} style={{ flexShrink: 1 }} - onClick={() => goToNodeId(node.id)} + onClick={() => goToNodeId(node.id, "pathBar")} >
{node.icon && } diff --git a/app/components/PathPreview.tsx b/app/components/PathPreview.tsx index fd7dbed..6635325 100644 --- a/app/components/PathPreview.tsx +++ b/app/components/PathPreview.tsx @@ -81,7 +81,8 @@ export function PathPreview({ : "disabled" }`} onClick={() => - isEnabled && goToNodeId(components[components.length - 1].id) + isEnabled && + goToNodeId(components[components.length - 1].id, "relatedValues") } >
{ diff --git a/app/hooks/useVirtualTree.ts b/app/hooks/useVirtualTree.ts index 9afee91..3a571b8 100644 --- a/app/hooks/useVirtualTree.ts +++ b/app/hooks/useVirtualTree.ts @@ -1,4 +1,4 @@ -import { useReducer, Reducer, useCallback, Dispatch } from "react"; +import { useReducer, Reducer, useCallback, Dispatch, useEffect } from "react"; import { useVirtual, VirtualItem } from "react-virtual"; type UseVirtualOptions = Parameters[0]; @@ -22,8 +22,11 @@ export type VirtualNode = { export type UseVirtualTreeInstance = { nodes: VirtualNode[]; + focusedNodeId: string | null; totalSize: number; toggleNode: (id: string) => void; + focusNode: (id: string) => void; + focusFirst: () => void; scrollToNode: (id: string) => void; getTreeProps: () => React.HTMLAttributes; }; @@ -49,7 +52,42 @@ type ToggleNodeAction = { id: string; }; -type TreeAction = ToggleNodeAction; +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; + id: string; + isCollapsed: boolean; +}; + +type MoveLeftAction = { + type: "MOVE_LEFT"; + source: KeyboardEvent | MouseEvent; + id: string; + isCollapsed: boolean; + hasChildren: boolean; +}; + +type FocusFirstAction = { + type: "FOCUS_FIRST"; +}; + +type TreeAction = + | ToggleNodeAction + | MoveNodeAction + | FocusNodeAction + | FocusFirstAction + | MoveRightAction + | MoveLeftAction; function expandNode( state: TreeState, @@ -100,6 +138,126 @@ export function useVirtualTree( return collapseNode(state, action.id); } } + case "FOCUS_NODE": { + 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": { + 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 (action.isCollapsed) { + return expandNode(state, action.id); + } + + const nodeIndex = state.items.findIndex( + (item) => item.id === action.id + ); + + 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 (action.hasChildren && !action.isCollapsed) { + return collapseNode(state, action.id); + } + + if (!action.hasChildren || action.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(action.id) + ); + + if (parentNodeIndex === -1) { + return state; + } + + const nextItem = state.items[parentNodeIndex]; + + return { + ...state, + focusedNodeId: nextItem.id, + }; + } + } default: return state; } @@ -150,9 +308,24 @@ export function useVirtualTree( }; }); - const toggleNode = useCallback((id: string) => { - dispatch({ type: "TOGGLE_NODE", id }); - }, []); + const toggleNode = useCallback( + (id: string) => { + dispatch({ type: "TOGGLE_NODE", id }); + }, + [dispatch] + ); + + const focusNode = useCallback( + (id: string) => { + dispatch({ type: "FOCUS_NODE", id }); + }, + [dispatch] + ); + + const focusFirst = useCallback( + () => dispatch({ type: "FOCUS_FIRST" }), + [dispatch] + ); // TODO: have this work with collapsed nodes const scrollToNode = useCallback( @@ -160,16 +333,26 @@ export function useVirtualTree( const itemIndex = state.items.findIndex((item) => item.id === id); if (itemIndex !== -1) { - rowVirtualizer.scrollToIndex(itemIndex, { align: "center" }); + rowVirtualizer.scrollToIndex(itemIndex, { align: "auto" }); } }, - [state.items, rowVirtualizer] + [state.items, rowVirtualizer.scrollToIndex] ); + useEffect(() => { + if (state.focusedNodeId) { + scrollToNode(state.focusedNodeId); + focusNode(state.focusedNodeId); + } + }, [state.focusedNodeId, scrollToNode, focusNode]); + return { nodes: allVirtualNodes, totalSize: rowVirtualizer.totalSize, toggleNode, + focusNode, + focusFirst, + focusedNodeId: state.focusedNodeId, getTreeProps: useCallback( () => ({ role: "tree", @@ -219,6 +402,76 @@ function createItemProps( "aria-posinset": pos, "aria-setsize": size, role: "treeitem", - tabIndex: node.id === state.focusedNodeId ? 0 : -1, + 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 }); + } + }, + onKeyDown: (e) => { + if (e.defaultPrevented) { + return; // Do nothing if the event was already processed + } + + if (node.id === state.focusedNodeId) { + 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", + id: node.id, + isCollapsed, + hasChildren: + typeof node.children !== "undefined" && + node.children.length > 0, + source: e.nativeEvent, + }); + e.preventDefault(); + + break; + } + case "Right": + case "ArrowRight": { + if (node.children && node.children.length > 0) { + dispatch({ + type: "MOVE_RIGHT", + id: node.id, + isCollapsed, + source: e.nativeEvent, + }); + e.preventDefault(); + } + + break; + } + } + } + }, }); } diff --git a/app/routes/j/$id.tsx b/app/routes/j/$id.tsx index 1f8e8d2..8df55a3 100644 --- a/app/routes/j/$id.tsx +++ b/app/routes/j/$id.tsx @@ -114,7 +114,7 @@ export default function JsonDocumentRoute() { - +
diff --git a/app/useColumnView/index.ts b/app/useColumnView/index.ts index 6c63f26..88f9c2d 100644 --- a/app/useColumnView/index.ts +++ b/app/useColumnView/index.ts @@ -39,6 +39,7 @@ export type ColumnViewInstanceState = { columns: Array; getColumnViewProps: () => ColumnViewProps; selectedNodeId?: string; + selectedNodeSource?: string; selectedPath: string[]; highlightedNodeId?: string; highlightedPath: string[]; @@ -54,7 +55,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; @@ -157,8 +158,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)); @@ -176,10 +177,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); @@ -202,6 +208,7 @@ export function useColumnView({ return { state: { selectedNodeId, + selectedNodeSource, selectedPath, selectedNodes, highlightedNodeId, @@ -218,6 +225,7 @@ export function useColumnView({ export type ColumnViewState = { selectedNodeId?: string; highlightedNodeId?: string; + selectedNodeSource?: string; history: Array>; historyCurrentIndex: number; nodeTable: NodeTable; @@ -227,6 +235,7 @@ export type ColumnViewState = { export type SetSelectedNodeIdAction = { type: "SET_SELECTED_NODE_ID"; id: string; + source: string; }; export type MoveSelectedNodeAction = { @@ -269,10 +278,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, }; } @@ -332,6 +345,7 @@ function columnViewReducer( ...state, selectedNodeId: action.id, highlightedNodeId: action.id, + selectedNodeSource: action.source, }; case "MOVE_DOWN": { if (state.highlightedNodeId === state.rootNodeId) { diff --git a/app/utilities/formatter.ts b/app/utilities/formatter.ts index 9cd9628..ff5a940 100644 --- a/app/utilities/formatter.ts +++ b/app/utilities/formatter.ts @@ -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": { diff --git a/package-lock.json b/package-lock.json index 8561bce..e359428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,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", @@ -925,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", @@ -14193,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", diff --git a/package.json b/package.json index bedb074..f5fcc3f 100644 --- a/package.json +++ b/package.json @@ -59,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", @@ -101,4 +99,4 @@ }, "sideEffects": false, "main": "dist/worker.js" -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 749e4b9..35cfa5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ "paths": { "~/*": ["./app/*"] }, + "skipDefaultLibCheck": true, + "skipLibCheck": true, // Remix takes care of building everything in `remix build`. "noEmit": true