From 7b1af026ea4b09b8cecb50e7af98129af8abc730 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 18 Jan 2024 12:46:34 -0800 Subject: [PATCH 01/36] First pass --- .../components/SquigglePlayground/index.tsx | 25 ++++++++++++++++ .../SquiggleViewer/ValueWithContextViewer.tsx | 8 ++++- .../SquiggleViewer/ViewerProvider.tsx | 30 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index 9974fb8574..a1ea42e5d2 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -175,6 +175,31 @@ export const SquigglePlayground: React.FC = ( ); + console.log(rightPanelRef); + function foo() { + console.log(rightPanelRef); + rightPanelRef.current?.onKeyPress("HERE" as string); + } + + useEffect(() => { + const handleKeyUp = (event) => { + // Check if 'up' key is pressed and the active element is not the text editor + if (event.key === "ArrowUp") { + console.log("'Up' key pressed outside the text editor"); + // Execute your action here + } + foo(); + }; + + // Attach the event listener + window.addEventListener("keydown", handleKeyUp); + + // Clean up the event listener + return () => { + window.removeEventListener("keydown", handleKeyUp); + }; + }, [rightPanelRef.current]); // Empty dependency array ensures this runs once on mount and on unmount + return ( = ({ const toggleCollapsed_ = useToggleCollapsed(); const focus = useFocus(); + const select = useSelect(); + const isSelected = useIsSelected(path); const { itemStore } = useViewerContext(); const itemState = itemStore.getStateOrInitialize(value); @@ -230,8 +234,10 @@ export const ValueWithContextViewer: FC = ({
select(path)} >
{collapsible && triangleToggle()} diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 1f469a8905..2c2c6f0ede 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -163,6 +163,8 @@ type ViewerContextShape = { globalSettings: PlaygroundSettings; focused: SqValuePath | undefined; setFocused: (value: SqValuePath | undefined) => void; + selected: SqValuePath | undefined; + setSelected: (value: SqValuePath | undefined) => void; editor?: CodeEditorHandle; itemStore: ItemStore; initialized: boolean; @@ -173,6 +175,8 @@ export const ViewerContext = createContext({ globalSettings: defaultPlaygroundSettings, focused: undefined, setFocused: () => undefined, + selected: undefined, + setSelected: () => undefined, editor: undefined, itemStore: new ItemStore(), handle: { @@ -281,6 +285,20 @@ export function useFocus() { }; } +export function useSelect() { + const { selected, setSelected } = useViewerContext(); + return (path: SqValuePath) => { + if (selected && pathAsString(selected) === pathAsString(path)) { + return; // nothing to do + } + if (path.isRoot()) { + setSelected(undefined); // selecting root nodes is not allowed + } else { + setSelected(path); + } + }; +} + export function useUnfocus() { const { setFocused } = useViewerContext(); return () => setFocused(undefined); @@ -291,6 +309,11 @@ export function useIsFocused(path: SqValuePath) { return focused && pathAsString(focused) === pathAsString(path); } +export function useIsSelected(path: SqValuePath) { + const { selected } = useViewerContext(); + return selected && pathAsString(selected) === pathAsString(path); +} + export function useMergedSettings(path: SqValuePath) { const { itemStore, globalSettings } = useViewerContext(); @@ -331,8 +354,13 @@ export const InnerViewerProvider = forwardRef( }; useImperativeHandle(ref, () => handle); + // onKeyPress(stroke: string) { + // console.log("Keystroke", stroke); + // }, + // })); const [focused, setFocused] = useState(); + const [selected, setSelected] = useState(); const globalSettings = useMemo(() => { return merge({}, defaultPlaygroundSettings, playgroundSettings); @@ -345,6 +373,8 @@ export const InnerViewerProvider = forwardRef( editor, focused, setFocused, + selected, + setSelected, itemStore, handle, initialized: true, From 43aa195cbaccd3b964ea76346f2cdbd61da6ba8b Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 18 Jan 2024 12:59:31 -0800 Subject: [PATCH 02/36] Minor enhancements --- .../components/SquigglePlayground/index.tsx | 10 ++----- .../SquiggleViewer/ViewerProvider.tsx | 27 +++++++++++-------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index a1ea42e5d2..be4ba79b0a 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -175,12 +175,6 @@ export const SquigglePlayground: React.FC = (
); - console.log(rightPanelRef); - function foo() { - console.log(rightPanelRef); - rightPanelRef.current?.onKeyPress("HERE" as string); - } - useEffect(() => { const handleKeyUp = (event) => { // Check if 'up' key is pressed and the active element is not the text editor @@ -188,7 +182,7 @@ export const SquigglePlayground: React.FC = ( console.log("'Up' key pressed outside the text editor"); // Execute your action here } - foo(); + rightPanelRef.current?.onKeyPress(event.key as string); }; // Attach the event listener @@ -198,7 +192,7 @@ export const SquigglePlayground: React.FC = ( return () => { window.removeEventListener("keydown", handleKeyUp); }; - }, [rightPanelRef.current]); // Empty dependency array ensures this runs once on mount and on unmount + }, []); // Empty dependency array ensures this runs once on mount and on unmount return ( diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 2c2c6f0ede..24628ac801 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -31,6 +31,7 @@ import { export type SquiggleViewerHandle = { viewValuePath(path: SqValuePath): void; + onKeyPress(stroke: string): void; }; type ItemHandle = { @@ -181,6 +182,7 @@ export const ViewerContext = createContext({ itemStore: new ItemStore(), handle: { viewValuePath: () => {}, + onKeyPress: () => {}, }, initialized: false, }); @@ -347,24 +349,27 @@ export const InnerViewerProvider = forwardRef( unstablePlaygroundSettings ); + const [focused, setFocused] = useState(); + const [selected, setSelected] = useState(); + + const globalSettings = useMemo(() => { + return merge({}, defaultPlaygroundSettings, playgroundSettings); + }, [playgroundSettings]); + const handle: SquiggleViewerHandle = { viewValuePath(path: SqValuePath) { itemStore.scrollToPath(path); }, + onKeyPress(stroke: string) { + console.log("Keystroke", stroke); + if (stroke === "Enter") { + console.log("Enter pressed"); + setFocused(selected); + } + }, }; useImperativeHandle(ref, () => handle); - // onKeyPress(stroke: string) { - // console.log("Keystroke", stroke); - // }, - // })); - - const [focused, setFocused] = useState(); - const [selected, setSelected] = useState(); - - const globalSettings = useMemo(() => { - return merge({}, defaultPlaygroundSettings, playgroundSettings); - }, [playgroundSettings]); return ( Date: Thu, 18 Jan 2024 15:35:43 -0800 Subject: [PATCH 03/36] Simple up-down working for RightView --- .../components/SquigglePlayground/index.tsx | 5 - .../SquiggleViewer/ValueWithContextViewer.tsx | 2 +- .../SquiggleViewer/ViewerProvider.tsx | 206 +++++++++++++++++- 3 files changed, 199 insertions(+), 14 deletions(-) diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index be4ba79b0a..d8bde6dbd2 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -177,11 +177,6 @@ export const SquigglePlayground: React.FC = ( useEffect(() => { const handleKeyUp = (event) => { - // Check if 'up' key is pressed and the active element is not the text editor - if (event.key === "ArrowUp") { - console.log("'Up' key pressed outside the text editor"); - // Execute your action here - } rightPanelRef.current?.onKeyPress(event.key as string); }; diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 2e6b0938b9..7830884cfc 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -139,7 +139,7 @@ export const ValueWithContextViewer: FC = ({ toggleCollapsed_(path); }; - const ref = useRegisterAsItemViewer(path); + const ref = useRegisterAsItemViewer(path, value, parentValue); // TODO - check that we're not in a situation where `isOpen` is false and `header` is hidden? // In that case, the output would look broken (empty). diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 24628ac801..db5da2f132 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -11,11 +11,11 @@ import { useState, } from "react"; -import { SqValuePath } from "@quri/squiggle-lang"; +import { SqValue, SqValuePath } from "@quri/squiggle-lang"; import { useForceUpdate } from "../../lib/hooks/useForceUpdate.js"; import { useStabilizeObjectIdentity } from "../../lib/hooks/useStabilizeObject.js"; -import { SqValueWithContext } from "../../lib/utility.js"; +import { SqValueWithContext, valueHasContext } from "../../lib/utility.js"; import { CalculatorState } from "../../widgets/CalculatorWidget/types.js"; import { CodeEditorHandle } from "../CodeEditor/index.js"; import { @@ -53,6 +53,141 @@ const defaultLocalItemState: LocalItemState = { settings: {}, }; +class PathTreeNode { + value: SqValueWithContext; + tree: PathTree; + parent: PathTreeNode | undefined; + children: PathTreeNode[] = []; + + constructor( + value: SqValueWithContext, + parent: PathTreeNode | undefined, + tree: PathTree + ) { + this.value = value; + this.parent = parent; + this.tree = tree; + } + + addChild(value: SqValueWithContext): PathTreeNode { + const node = new PathTreeNode(value, this, this.tree); + this.children.push(node); + return node; + } + + pathName() { + return pathAsString(this.value.context.path); + } + + siblings() { + return this.parent?.children.map((r) => r.pathName()) || []; + } + + prevSibling() { + const siblings = this.siblings(); + const index = siblings.indexOf(this.pathName()); + if (index === -1) { + return undefined; + } else if (index === 0) { + return undefined; + } + return siblings[index - 1]; + } + + nextSibling() { + const siblings = this.siblings(); + const index = siblings.indexOf(this.pathName()); + if (index === -1) { + return undefined; + } else if (index === siblings.length - 1) { + return undefined; + } + return siblings[index + 1]; + } + + next(): PathTreeNode | undefined { + if (this.children.length > 0) { + return this.children[0]; + } else { + const _nextSibling = this.nextSibling(); + if (!_nextSibling) { + const parentSibling = this.parent?.nextSibling(); + return parentSibling + ? this.tree.findFromPathName(parentSibling) + : undefined; + } else { + return this.tree.findFromPathName(_nextSibling); + } + } + } + + prev(): PathTreeNode | undefined { + const _prevSibling = this.prevSibling(); + if (!_prevSibling) { + return this.parent; + } else { + const prev = this.tree.findFromPathName(_prevSibling); + if (prev && prev.children.length > 0) { + return prev.children[prev.children.length - 1]; + } else { + return prev; + } + } + } + + toJS() { + return { + value: this.value, + children: this.children.map((child) => child.toJS()), + }; + } +} + +class PathTree { + root: PathTreeNode; + nodes: Map = new Map(); + + constructor(rootNote: SqValueWithContext) { + this.root = new PathTreeNode(rootNote, undefined, this); + this._addNode(this.root); + } + + _addNode(value: PathTreeNode) { + const pathName = value.pathName(); + this.nodes.set(pathName, value); + } + + _removeNode(value: PathTreeNode) { + this.nodes.delete(value.pathName()); + value.children.forEach((child) => this._removeNode(child)); + } + + removeNode(value: SqValueWithContext) { + const node = this.nodes[pathAsString(value.context.path)]; + if (node) { + this._removeNode(node); + } + } + + toJS() { + return this.root.toJS(); + } + + addFromSqValue(child: SqValueWithContext, parent: SqValueWithContext) { + const path = pathAsString(child.context.path); + if (!this.nodes.has(path)) { + const parentNode = this.nodes.get(pathAsString(parent.context.path)); + if (parentNode) { + this._addNode(parentNode.addChild(child)); + } + } + } + + findFromPathName(pathName: string): PathTreeNode | undefined { + return this.nodes.get(pathName); + } +} + /** * `ItemStore` is used for caching and for passing settings down the tree. * It allows us to avoid React tree rerenders on settings changes; instead, we can rerender individual item viewers on demand. @@ -162,6 +297,8 @@ type ViewerContextShape = { // Instead, we keep `localItemState` in local state and notify the global context via `setLocalItemState` to pass them down the component tree again if it got rebuilt from scratch. // See ./SquiggleViewer.tsx and ./ValueWithContextViewer.tsx for other implementation details on this. globalSettings: PlaygroundSettings; + pathTree: PathTree | undefined; + setPathTree: (value: PathTree | undefined) => void; focused: SqValuePath | undefined; setFocused: (value: SqValuePath | undefined) => void; selected: SqValuePath | undefined; @@ -174,6 +311,8 @@ type ViewerContextShape = { export const ViewerContext = createContext({ globalSettings: defaultPlaygroundSettings, + pathTree: undefined, + setPathTree: () => undefined, focused: undefined, setFocused: () => undefined, selected: undefined, @@ -195,9 +334,13 @@ export function useViewerContext() { // This allows us to do two things later: // 1. Implement `store.scrollToPath`. // 2. Re-render individual item viewers on demand, for example on "Collapse Children" menu action. -export function useRegisterAsItemViewer(path: SqValuePath) { +export function useRegisterAsItemViewer( + path: SqValuePath, + value: SqValueWithContext, + parent: SqValue | undefined +) { const ref = useRef(null); - const { itemStore } = useViewerContext(); + const { itemStore, pathTree, setPathTree } = useViewerContext(); /** * Since `ViewerContext` doesn't store settings, this component won't rerender when `setSettings` is called. @@ -213,7 +356,22 @@ export function useRegisterAsItemViewer(path: SqValuePath) { } itemStore.registerItemHandle(path, { element, forceUpdate }); - return () => itemStore.unregisterItemHandle(path); + + if (!pathTree) { + if (!parent) { + const newPathTree = new PathTree(value); + setPathTree(newPathTree); + } + } else if (parent) { + if (valueHasContext(parent)) { + pathTree.addFromSqValue(value, parent); + } + } + + return () => { + itemStore.unregisterItemHandle(path); + pathTree?.removeNode(value); + }; }); return ref; @@ -351,6 +509,7 @@ export const InnerViewerProvider = forwardRef( const [focused, setFocused] = useState(); const [selected, setSelected] = useState(); + const [pathTree, setPathTree] = useState(); const globalSettings = useMemo(() => { return merge({}, defaultPlaygroundSettings, playgroundSettings); @@ -361,10 +520,39 @@ export const InnerViewerProvider = forwardRef( itemStore.scrollToPath(path); }, onKeyPress(stroke: string) { - console.log("Keystroke", stroke); if (stroke === "Enter") { - console.log("Enter pressed"); - setFocused(selected); + if (selected) { + if (selected === focused) { + setFocused(undefined); + } else { + setFocused(selected); + } + } + } + if (stroke === "ArrowDown") { + if (selected) { + const next = pathTree?.nodes.get(pathAsString(selected))?.next(); + if (next) { + setSelected(next.value.context.path); + } + } + } + if (stroke === "ArrowUp") { + if (selected) { + const prev = pathTree?.nodes.get(pathAsString(selected))?.prev(); + if (prev) { + setSelected(prev.value.context.path); + } + } + } + if (stroke === "ArrowLeft" || stroke === "ArrowRight") { + if (selected) { + itemStore.setState(selected, (state) => ({ + ...state, + collapsed: !state?.collapsed, + })); + itemStore.forceUpdate(selected); + } } }, }; @@ -380,6 +568,8 @@ export const InnerViewerProvider = forwardRef( setFocused, selected, setSelected, + pathTree, + setPathTree, itemStore, handle, initialized: true, From c6cdb43bdd13ec6b5ebde38cae4175e01cfd4803 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 18 Jan 2024 16:27:38 -0800 Subject: [PATCH 04/36] ScrollToPath for up-down --- .../components/SquigglePlayground/index.tsx | 3 +- .../SquiggleViewer/ViewerProvider.tsx | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index d8bde6dbd2..bd09622167 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -176,7 +176,8 @@ export const SquigglePlayground: React.FC = ( ); useEffect(() => { - const handleKeyUp = (event) => { + const handleKeyUp = (event: KeyboardEvent) => { + event.preventDefault(); rightPanelRef.current?.onKeyPress(event.key as string); }; diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index db5da2f132..f94ae0da52 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -56,6 +56,7 @@ const defaultLocalItemState: LocalItemState = { class PathTreeNode { value: SqValueWithContext; tree: PathTree; + path: string; parent: PathTreeNode | undefined; children: PathTreeNode[] = []; @@ -67,6 +68,7 @@ class PathTreeNode { this.value = value; this.parent = parent; this.tree = tree; + this.path = pathAsString(value.context.path); } addChild(value: SqValueWithContext): PathTreeNode { @@ -75,6 +77,10 @@ class PathTreeNode { return node; } + removeChild(node: PathTreeNode) { + this.children = this.children.filter((child) => child.path !== node.path); + } + pathName() { return pathAsString(this.value.context.path); } @@ -163,8 +169,11 @@ class PathTree { } removeNode(value: SqValueWithContext) { - const node = this.nodes[pathAsString(value.context.path)]; + const node: PathTreeNode | undefined = this.nodes.get( + pathAsString(value.context.path) + ); if (node) { + node.parent?.removeChild(node); this._removeNode(node); } } @@ -188,6 +197,22 @@ class PathTree { } } +function isElementInView(element: HTMLElement) { + const elementRect = element.getBoundingClientRect(); + const container = document.querySelector( + '[data-testid="dynamic-viewer-result"]' + ); + if (!container) { + return false; + } + + const containerRect = container.getBoundingClientRect(); + + return ( + elementRect.top >= containerRect.top && + elementRect.top + 20 <= containerRect.bottom + ); +} /** * `ItemStore` is used for caching and for passing settings down the tree. * It allows us to avoid React tree rerenders on settings changes; instead, we can rerender individual item viewers on demand. @@ -287,9 +312,13 @@ class ItemStore { scrollToPath(path: SqValuePath) { this.handles[pathAsString(path)]?.element.scrollIntoView({ - behavior: "smooth", + behavior: "instant", }); } + + isInView(path: SqValuePath) { + return isElementInView(this.handles[pathAsString(path)]?.element); + } } type ViewerContextShape = { @@ -369,8 +398,8 @@ export function useRegisterAsItemViewer( } return () => { - itemStore.unregisterItemHandle(path); - pathTree?.removeNode(value); + itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often + // pathTree?.removeNode(value); }; }); @@ -534,6 +563,9 @@ export const InnerViewerProvider = forwardRef( const next = pathTree?.nodes.get(pathAsString(selected))?.next(); if (next) { setSelected(next.value.context.path); + if (!itemStore.isInView(next.value.context.path)) { + itemStore.scrollToPath(next.value.context.path); + } } } } @@ -542,6 +574,9 @@ export const InnerViewerProvider = forwardRef( const prev = pathTree?.nodes.get(pathAsString(selected))?.prev(); if (prev) { setSelected(prev.value.context.path); + if (!itemStore.isInView(prev.value.context.path)) { + itemStore.scrollToPath(prev.value.context.path); + } } } } @@ -551,6 +586,9 @@ export const InnerViewerProvider = forwardRef( ...state, collapsed: !state?.collapsed, })); + if (!itemStore.isInView(selected)) { + itemStore.scrollToPath(selected); + } itemStore.forceUpdate(selected); } } From dd38c4698ca6ac8eb145dcb986744c1c5aac7cf5 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 18 Jan 2024 16:36:55 -0800 Subject: [PATCH 05/36] Right scroll determines left scroll --- .../src/components/CodeEditor/index.tsx | 2 +- .../SquiggleViewer/ViewerProvider.tsx | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/components/src/components/CodeEditor/index.tsx b/packages/components/src/components/CodeEditor/index.tsx index 8f213f5a71..ea75d600ac 100644 --- a/packages/components/src/components/CodeEditor/index.tsx +++ b/packages/components/src/components/CodeEditor/index.tsx @@ -35,7 +35,7 @@ export const CodeEditor = forwardRef( selection: { anchor: position }, scrollIntoView: true, }); - view.focus(); + // view.focus(); }; useImperativeHandle(ref, () => ({ diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index f94ae0da52..8af76c2baa 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -544,6 +544,16 @@ export const InnerViewerProvider = forwardRef( return merge({}, defaultPlaygroundSettings, playgroundSettings); }, [playgroundSettings]); + function scrollToPath(path: SqValuePath) { + const location = pathTree?.nodes + .get(pathAsString(path)) + ?.value?.context?.findLocation(); + + if (location) { + editor?.scrollTo(location.start.offset); + } + } + const handle: SquiggleViewerHandle = { viewValuePath(path: SqValuePath) { itemStore.scrollToPath(path); @@ -562,9 +572,11 @@ export const InnerViewerProvider = forwardRef( if (selected) { const next = pathTree?.nodes.get(pathAsString(selected))?.next(); if (next) { - setSelected(next.value.context.path); - if (!itemStore.isInView(next.value.context.path)) { - itemStore.scrollToPath(next.value.context.path); + const newPath = next.value.context.path; + setSelected(newPath); + scrollToPath(newPath); + if (!itemStore.isInView(newPath)) { + itemStore.scrollToPath(newPath); } } } @@ -573,9 +585,11 @@ export const InnerViewerProvider = forwardRef( if (selected) { const prev = pathTree?.nodes.get(pathAsString(selected))?.prev(); if (prev) { - setSelected(prev.value.context.path); - if (!itemStore.isInView(prev.value.context.path)) { - itemStore.scrollToPath(prev.value.context.path); + const newPath = prev.value.context.path; + setSelected(newPath); + scrollToPath(newPath); + if (!itemStore.isInView(newPath)) { + itemStore.scrollToPath(newPath); } } } @@ -592,6 +606,11 @@ export const InnerViewerProvider = forwardRef( itemStore.forceUpdate(selected); } } + if (stroke === "e") { + if (selected) { + scrollToPath(selected); + } + } }, }; From be90365c8323352c616cd641b06ddc2139c9f716 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 18 Jan 2024 21:34:29 -0800 Subject: [PATCH 06/36] Tree refactor --- .../SquiggleViewer/ViewerProvider.tsx | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 8af76c2baa..77b4edafd9 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -69,6 +69,21 @@ class PathTreeNode { this.parent = parent; this.tree = tree; this.path = pathAsString(value.context.path); + this.isEqual = this.isEqual.bind(this); + } + + isEqual(other: PathTreeNode) { + return this.path === other.path; + } + + isCollapsed() { + return this.tree.itemStore.getState(this.value.context.path).collapsed; + } + + isVisible() { + const _isVisible = + !this.parent || !this.tree.itemStore.state[this.parent.path].collapsed; + return _isVisible; } addChild(value: SqValueWithContext): PathTreeNode { @@ -85,13 +100,17 @@ class PathTreeNode { return pathAsString(this.value.context.path); } - siblings() { + siblingsValues(): PathTreeNode[] { + return this.parent?.children || []; + } + + siblings(): string[] { return this.parent?.children.map((r) => r.pathName()) || []; } prevSibling() { - const siblings = this.siblings(); - const index = siblings.indexOf(this.pathName()); + const siblings = this.siblingsValues(); + const index = siblings.findIndex(this.isEqual); if (index === -1) { return undefined; } else if (index === 0) { @@ -101,8 +120,8 @@ class PathTreeNode { } nextSibling() { - const siblings = this.siblings(); - const index = siblings.indexOf(this.pathName()); + const siblings = this.siblingsValues(); + const index = siblings.findIndex(this.isEqual); if (index === -1) { return undefined; } else if (index === siblings.length - 1) { @@ -112,17 +131,23 @@ class PathTreeNode { } next(): PathTreeNode | undefined { - if (this.children.length > 0) { + if (this.children.length > 0 && !this.isCollapsed()) { return this.children[0]; } else { const _nextSibling = this.nextSibling(); if (!_nextSibling) { const parentSibling = this.parent?.nextSibling(); - return parentSibling - ? this.tree.findFromPathName(parentSibling) - : undefined; + if (parentSibling) { + return parentSibling; + } else { + const grantparentSibling = this.parent?.parent?.nextSibling(); + if (grantparentSibling) { + return grantparentSibling; + } + } + return undefined; } else { - return this.tree.findFromPathName(_nextSibling); + return _nextSibling; } } } @@ -132,8 +157,8 @@ class PathTreeNode { if (!_prevSibling) { return this.parent; } else { - const prev = this.tree.findFromPathName(_prevSibling); - if (prev && prev.children.length > 0) { + const prev = _prevSibling; + if (prev && prev.children.length > 0 && !prev.isCollapsed()) { return prev.children[prev.children.length - 1]; } else { return prev; @@ -152,10 +177,12 @@ class PathTreeNode { class PathTree { root: PathTreeNode; nodes: Map = new Map(); + itemStore: ItemStore; - constructor(rootNote: SqValueWithContext) { + constructor(rootNote: SqValueWithContext, itemStore) { this.root = new PathTreeNode(rootNote, undefined, this); this._addNode(this.root); + this.itemStore = itemStore; } _addNode(value: PathTreeNode) { @@ -388,7 +415,7 @@ export function useRegisterAsItemViewer( if (!pathTree) { if (!parent) { - const newPathTree = new PathTree(value); + const newPathTree = new PathTree(value, itemStore); setPathTree(newPathTree); } } else if (parent) { @@ -600,9 +627,9 @@ export const InnerViewerProvider = forwardRef( ...state, collapsed: !state?.collapsed, })); - if (!itemStore.isInView(selected)) { - itemStore.scrollToPath(selected); - } + // if (!itemStore.isInView(selected)) { + // itemStore.scrollToPath(selected); + // } itemStore.forceUpdate(selected); } } From b60259a8c9be5dc11332cf4e2a3ae97da707c298 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Fri, 19 Jan 2024 08:58:40 -0800 Subject: [PATCH 07/36] First round of refactors --- .../SquiggleViewer/ValueWithContextViewer.tsx | 14 +- .../SquiggleViewer/ViewerProvider.tsx | 255 +++++++++++------- 2 files changed, 177 insertions(+), 92 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 7830884cfc..0b0a87cc2d 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -227,6 +227,17 @@ export const ValueWithContextViewer: FC = ({ } }; + const extraHeaderClasses = () => { + if (header === "large") { + return "mb-2"; + } else { + if (isSelected) { + return "bg-blue-100 hover:bg-blue-200"; + } + return "hover:bg-stone-100 rounded-sm"; + } + }; + return (
@@ -234,8 +245,7 @@ export const ValueWithContextViewer: FC = ({
select(path)} > diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 77b4edafd9..ab45a4e7a6 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -57,6 +57,7 @@ class PathTreeNode { value: SqValueWithContext; tree: PathTree; path: string; + fullPath: SqValuePath; parent: PathTreeNode | undefined; children: PathTreeNode[] = []; @@ -69,6 +70,7 @@ class PathTreeNode { this.parent = parent; this.tree = tree; this.path = pathAsString(value.context.path); + this.fullPath = value.context.path; this.isEqual = this.isEqual.bind(this); } @@ -76,14 +78,12 @@ class PathTreeNode { return this.path === other.path; } - isCollapsed() { - return this.tree.itemStore.getState(this.value.context.path).collapsed; + isRoot() { + return this.isEqual(this.tree.root); } - isVisible() { - const _isVisible = - !this.parent || !this.tree.itemStore.state[this.parent.path].collapsed; - return _isVisible; + isCollapsed() { + return this.tree.itemStore.getState(this.value.context.path).collapsed; } addChild(value: SqValueWithContext): PathTreeNode { @@ -100,70 +100,64 @@ class PathTreeNode { return pathAsString(this.value.context.path); } - siblingsValues(): PathTreeNode[] { + siblings(): PathTreeNode[] { return this.parent?.children || []; } - siblings(): string[] { - return this.parent?.children.map((r) => r.pathName()) || []; + getParentIndex() { + const siblings = this.siblings(); + return siblings.findIndex(this.isEqual); } prevSibling() { - const siblings = this.siblingsValues(); - const index = siblings.findIndex(this.isEqual); + const index = this.getParentIndex(); if (index === -1) { return undefined; } else if (index === 0) { return undefined; } - return siblings[index - 1]; + return this.siblings()[index - 1]; } nextSibling() { - const siblings = this.siblingsValues(); - const index = siblings.findIndex(this.isEqual); + const index = this.getParentIndex(); if (index === -1) { return undefined; - } else if (index === siblings.length - 1) { + } else if (index === this.siblings().length - 1) { return undefined; } - return siblings[index + 1]; + return this.siblings()[index + 1]; } - next(): PathTreeNode | undefined { - if (this.children.length > 0 && !this.isCollapsed()) { - return this.children[0]; + hasVisibleChildren() { + return this.children.length > 0 && !this.isCollapsed(); + } + + findLastVisibleChild(): PathTreeNode | undefined { + if (this.hasVisibleChildren()) { + const lastChild = this.children[this.children.length - 1]; + return lastChild.findLastVisibleChild() || lastChild; } else { - const _nextSibling = this.nextSibling(); - if (!_nextSibling) { - const parentSibling = this.parent?.nextSibling(); - if (parentSibling) { - return parentSibling; - } else { - const grantparentSibling = this.parent?.parent?.nextSibling(); - if (grantparentSibling) { - return grantparentSibling; - } - } - return undefined; - } else { - return _nextSibling; - } + return this; } } + nextAvailableSibling(): PathTreeNode | undefined { + return this.nextSibling() || this.parent?.nextAvailableSibling(); + } + + next(): PathTreeNode | undefined { + return this.children.length > 0 && !this.isCollapsed() + ? this.children[0] + : this.nextAvailableSibling(); + } + prev(): PathTreeNode | undefined { - const _prevSibling = this.prevSibling(); - if (!_prevSibling) { + const prevSibling = this.prevSibling(); + if (!prevSibling) { return this.parent; - } else { - const prev = _prevSibling; - if (prev && prev.children.length > 0 && !prev.isCollapsed()) { - return prev.children[prev.children.length - 1]; - } else { - return prev; - } } + return prevSibling.findLastVisibleChild(); } toJS() { @@ -195,16 +189,19 @@ class PathTree { value.children.forEach((child) => this._removeNode(child)); } - removeNode(value: SqValueWithContext) { - const node: PathTreeNode | undefined = this.nodes.get( - pathAsString(value.context.path) - ); + removeNode(value: SqValueWithContext): void { + const node = this.nodes.get(pathAsString(value.context.path)); if (node) { node.parent?.removeChild(node); - this._removeNode(node); + this.recursivelyRemoveNode(node); } } + recursivelyRemoveNode(node: PathTreeNode): void { + this.nodes.delete(node.pathName()); + node.children.forEach((child) => this.recursivelyRemoveNode(child)); + } + toJS() { return this.root.toJS(); } @@ -338,6 +335,7 @@ class ItemStore { } scrollToPath(path: SqValuePath) { + // setFocused(path); this.handles[pathAsString(path)]?.element.scrollIntoView({ behavior: "instant", }); @@ -547,6 +545,19 @@ type Props = PropsWithChildren<{ editor?: CodeEditorHandle; }>; +type ArrowEvent = + | "ArrowDown" + | "ArrowUp" + | "ArrowLeft" + | "ArrowRight" + | "Enter"; + +function isArrowEvent(str: string): str is ArrowEvent { + return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( + str + ); +} + export const InnerViewerProvider = forwardRef( ( { partialPlaygroundSettings: unstablePlaygroundSettings, editor, children }, @@ -581,61 +592,125 @@ export const InnerViewerProvider = forwardRef( } } - const handle: SquiggleViewerHandle = { - viewValuePath(path: SqValuePath) { - itemStore.scrollToPath(path); - }, - onKeyPress(stroke: string) { - if (stroke === "Enter") { - if (selected) { - if (selected === focused) { + function focusArrowEvent( + event: ArrowEvent, + pathTree: PathTree, + focused: SqValuePath + ) { + const node = pathTree.nodes.get(pathAsString(focused)); + switch (event) { + case "ArrowDown": { + const newItem = node?.children[0]; + if (newItem) { + setSelected(newItem.fullPath); + } + break; + } + case "ArrowUp": { + const newItem = node?.parent; + if (newItem) { + if (newItem.isRoot()) { setFocused(undefined); } else { - setFocused(selected); + setFocused(newItem.fullPath); + setSelected(newItem.fullPath); + scrollToPath(newItem.fullPath); } } + break; } - if (stroke === "ArrowDown") { - if (selected) { - const next = pathTree?.nodes.get(pathAsString(selected))?.next(); - if (next) { - const newPath = next.value.context.path; - setSelected(newPath); - scrollToPath(newPath); - if (!itemStore.isInView(newPath)) { - itemStore.scrollToPath(newPath); - } + case "ArrowLeft": { + const newItem = node?.prevSibling(); + if (newItem) { + setFocused(newItem.fullPath); + setSelected(newItem.fullPath); + scrollToPath(newItem.fullPath); + } + break; + } + case "ArrowRight": { + const newItem = node?.nextSibling(); + if (newItem) { + setFocused(newItem.fullPath); + setSelected(newItem.fullPath); + scrollToPath(newItem.fullPath); + } + break; + } + case "Enter": { + setFocused(undefined); + break; + } + } + } + + function selectedUnfocusedArrowEvent( + event: ArrowEvent, + pathTree: PathTree, + selected: SqValuePath + ) { + const node = pathTree.nodes.get(pathAsString(selected)); + switch (event) { + case "ArrowDown": { + const newItem = node?.next(); + if (newItem) { + const newPath = newItem.value.context.path; + setSelected(newPath); + scrollToPath(newPath); + if (!itemStore.isInView(newPath)) { + itemStore.scrollToPath(newPath); } } + break; } - if (stroke === "ArrowUp") { - if (selected) { - const prev = pathTree?.nodes.get(pathAsString(selected))?.prev(); - if (prev) { - const newPath = prev.value.context.path; - setSelected(newPath); - scrollToPath(newPath); - if (!itemStore.isInView(newPath)) { - itemStore.scrollToPath(newPath); - } + case "ArrowUp": { + const newItem = node?.prev(); + if (newItem) { + const newPath = newItem.value.context.path; + setSelected(newPath); + scrollToPath(newPath); + if (!itemStore.isInView(newPath)) { + itemStore.scrollToPath(newPath); } } + break; + } + case "ArrowLeft": { + const newItem = node?.parent; + newItem && !newItem.isRoot() && setSelected(newItem.fullPath); + break; } - if (stroke === "ArrowLeft" || stroke === "ArrowRight") { - if (selected) { - itemStore.setState(selected, (state) => ({ - ...state, - collapsed: !state?.collapsed, - })); - // if (!itemStore.isInView(selected)) { - // itemStore.scrollToPath(selected); - // } - itemStore.forceUpdate(selected); + case "ArrowRight": { + itemStore.setState(selected, (state) => ({ + ...state, + collapsed: !state?.collapsed, + })); + if (!itemStore.isInView(selected)) { + itemStore.scrollToPath(selected); } + itemStore.forceUpdate(selected); + break; + } + case "Enter": { + setFocused(selected); + break; } - if (stroke === "e") { - if (selected) { - scrollToPath(selected); + } + } + + const handle: SquiggleViewerHandle = { + viewValuePath(path: SqValuePath) { + setSelected(path); + itemStore.scrollToPath(path); + }, + onKeyPress(stroke: string) { + const arrowEvent = isArrowEvent(stroke) ? stroke : undefined; + + if (arrowEvent && pathTree) { + if (focused && selected && focused === selected) { + focusArrowEvent(arrowEvent, pathTree, focused); + } else if (selected) { + selectedUnfocusedArrowEvent(arrowEvent, pathTree, selected); } } }, From 1472c1ccf62e265e5e4687ecb7f849e4c2c71601 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Fri, 19 Jan 2024 20:47:44 -0800 Subject: [PATCH 08/36] More cleanup --- .../SquiggleViewer/ViewerProvider.tsx | 161 +++++++++--------- .../squiggle-lang/src/public/SqValuePath.ts | 18 ++ 2 files changed, 97 insertions(+), 82 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 85e188bc99..e8892db3ed 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -56,66 +56,78 @@ const defaultLocalItemState: LocalItemState = { }; class PathTreeNode { - value: SqValueWithContext; - tree: PathTree; - path: string; - fullPath: SqValuePath; + readonly tree: PathTree; + readonly fullPath: SqValuePath; parent: PathTreeNode | undefined; children: PathTreeNode[] = []; constructor( - value: SqValueWithContext, + path: SqValuePath, parent: PathTreeNode | undefined, tree: PathTree ) { - this.value = value; this.parent = parent; this.tree = tree; - this.path = pathAsString(value.context.path); - this.fullPath = value.context.path; + this.fullPath = path; this.isEqual = this.isEqual.bind(this); } - isEqual(other: PathTreeNode) { - return this.path === other.path; + toString() { + return pathAsString(this.fullPath); + } + + private isEqual(other: PathTreeNode) { + return this.fullPath.isEqual(other.fullPath); } isRoot() { - return this.isEqual(this.tree.root); + return this.fullPath.isRoot(); } - isCollapsed() { - return this.tree.itemStore.getState(this.value.context.path).collapsed; + private isCollapsed() { + return this.tree.itemStore.getState(this.fullPath).collapsed; } - addChild(value: SqValueWithContext): PathTreeNode { - const node = new PathTreeNode(value, this, this.tree); - this.children.push(node); - return node; + private childrenAreVisible() { + return !this.isCollapsed(); } - removeChild(node: PathTreeNode) { - this.children = this.children.filter((child) => child.path !== node.path); + lastChild(): PathTreeNode | undefined { + return this.children[this.children.length - 1]; } - pathName() { - return pathAsString(this.value.context.path); + addChild(path: SqValuePath): PathTreeNode | undefined { + //We don't really need this alreadyExists check, as this normally is just called by PathTree.addNode, which already checks for existence, but seems safe. + const alreadyExists = this.children.some((child) => + child.fullPath.isEqual(path) + ); + if (!alreadyExists) { + const node = new PathTreeNode(path, this, this.tree); + this.children.push(node); + return node; + } + } + + removeChild(node: PathTreeNode) { + this.children = this.children.filter( + (child) => !child.fullPath.isEqual(node.fullPath) + ); } siblings(): PathTreeNode[] { return this.parent?.children || []; } - getParentIndex() { + private getParentIndex() { const siblings = this.siblings(); return siblings.findIndex(this.isEqual); } prevSibling() { const index = this.getParentIndex(); - if (index === -1) { - return undefined; - } else if (index === 0) { + const isRootOrError = index === -1; + const isFirstSibling = index === 0; + if (isRootOrError || isFirstSibling) { return undefined; } return this.siblings()[index - 1]; @@ -123,28 +135,24 @@ class PathTreeNode { nextSibling() { const index = this.getParentIndex(); - if (index === -1) { - return undefined; - } else if (index === this.siblings().length - 1) { + const isRootOrError = index === -1; + const isLastSibling = index === this.siblings().length - 1; + if (isRootOrError || isLastSibling) { return undefined; } return this.siblings()[index + 1]; } - hasVisibleChildren() { - return this.children.length > 0 && !this.isCollapsed(); - } - - findLastVisibleChild(): PathTreeNode | undefined { - if (this.hasVisibleChildren()) { - const lastChild = this.children[this.children.length - 1]; - return lastChild.findLastVisibleChild() || lastChild; + private lastVisibleSubChild(): PathTreeNode | undefined { + if (this.children.length > 0 && this.childrenAreVisible()) { + const lastChild = this.lastChild(); + return lastChild?.lastVisibleSubChild() || lastChild; } else { return this; } } - nextAvailableSibling(): PathTreeNode | undefined { + private nextAvailableSibling(): PathTreeNode | undefined { return this.nextSibling() || this.parent?.nextAvailableSibling(); } @@ -159,68 +167,58 @@ class PathTreeNode { if (!prevSibling) { return this.parent; } - return prevSibling.findLastVisibleChild(); - } - - toJS() { - return { - value: this.value, - children: this.children.map((child) => child.toJS()), - }; + return prevSibling.lastVisibleSubChild(); } } class PathTree { - root: PathTreeNode; + readonly root: PathTreeNode; nodes: Map = new Map(); + values: Map = new Map(); // Maybe there's a better place this could go? itemStore: ItemStore; constructor(rootNote: SqValueWithContext, itemStore) { - this.root = new PathTreeNode(rootNote, undefined, this); - this._addNode(this.root); + this.root = new PathTreeNode(rootNote.context.path, undefined, this); + this._addNode(this.root, rootNote); this.itemStore = itemStore; } - _addNode(value: PathTreeNode) { - const pathName = value.pathName(); - this.nodes.set(pathName, value); + private _addNode(node: PathTreeNode, value: SqValueWithContext) { + this.nodes.set(node.toString(), node); + this.values.set(node.toString(), value); } - _removeNode(value: PathTreeNode) { - this.nodes.delete(value.pathName()); - value.children.forEach((child) => this._removeNode(child)); + private _removeNode(node: PathTreeNode) { + this.nodes.delete(node.toString()); + this.values.delete(node.toString()); + node.parent?.removeChild(node); + node.children.forEach((child) => this._removeNode(child)); } - removeNode(value: SqValueWithContext): void { - const node = this.nodes.get(pathAsString(value.context.path)); - if (node) { - node.parent?.removeChild(node); - this.recursivelyRemoveNode(node); - } + getNode(path: SqValuePath): PathTreeNode | undefined { + return this.nodes.get(pathAsString(path)); } - recursivelyRemoveNode(node: PathTreeNode): void { - this.nodes.delete(node.pathName()); - node.children.forEach((child) => this.recursivelyRemoveNode(child)); + getValue(path: SqValuePath): SqValueWithContext | undefined { + return this.values.get(pathAsString(path)); } - toJS() { - return this.root.toJS(); + removeNode(value: SqValueWithContext): void { + const node = this.getNode(value.context.path); + if (node) { + this._removeNode(node); + } } - addFromSqValue(child: SqValueWithContext, parent: SqValueWithContext) { - const path = pathAsString(child.context.path); - if (!this.nodes.has(path)) { - const parentNode = this.nodes.get(pathAsString(parent.context.path)); + addNode(child: SqValueWithContext, parent: SqValueWithContext) { + if (!this.getNode(child.context.path)) { + const parentNode = this.getNode(parent.context.path); if (parentNode) { - this._addNode(parentNode.addChild(child)); + const newNode = parentNode.addChild(child.context.path); + newNode && this._addNode(newNode, child); } } } - - findFromPathName(pathName: string): PathTreeNode | undefined { - return this.nodes.get(pathName); - } } function isElementInView(element: HTMLElement) { @@ -422,7 +420,7 @@ export function useRegisterAsItemViewer( } } else if (parent) { if (valueHasContext(parent)) { - pathTree.addFromSqValue(value, parent); + pathTree.addNode(value, parent); } } @@ -598,9 +596,8 @@ export const InnerViewerProvider = forwardRef( }, [playgroundSettings]); function scrollToPath(path: SqValuePath) { - const location = pathTree?.nodes - .get(pathAsString(path)) - ?.value?.context?.findLocation(); + const value = pathTree?.getValue(path); + const location = value?.context?.findLocation(); if (location) { editor?.scrollTo(location.start.offset); @@ -612,7 +609,7 @@ export const InnerViewerProvider = forwardRef( pathTree: PathTree, focused: SqValuePath ) { - const node = pathTree.nodes.get(pathAsString(focused)); + const node = pathTree.getNode(focused); switch (event) { case "ArrowDown": { const newItem = node?.children[0]; @@ -664,12 +661,12 @@ export const InnerViewerProvider = forwardRef( pathTree: PathTree, selected: SqValuePath ) { - const node = pathTree.nodes.get(pathAsString(selected)); + const node = pathTree.getNode(selected); switch (event) { case "ArrowDown": { const newItem = node?.next(); if (newItem) { - const newPath = newItem.value.context.path; + const newPath = newItem.fullPath; setSelected(newPath); scrollToPath(newPath); if (!itemStore.isInView(newPath)) { @@ -681,7 +678,7 @@ export const InnerViewerProvider = forwardRef( case "ArrowUp": { const newItem = node?.prev(); if (newItem) { - const newPath = newItem.value.context.path; + const newPath = newItem.fullPath; setSelected(newPath); scrollToPath(newPath); if (!itemStore.isInView(newPath)) { diff --git a/packages/squiggle-lang/src/public/SqValuePath.ts b/packages/squiggle-lang/src/public/SqValuePath.ts index 77eb07dd6e..c4be0d742b 100644 --- a/packages/squiggle-lang/src/public/SqValuePath.ts +++ b/packages/squiggle-lang/src/public/SqValuePath.ts @@ -73,6 +73,20 @@ export class SqPathItem { return "Calculator"; } } + + uid(): string { + const item = this.value; + switch (item.type) { + case "dictKey": + return `DictKey:(${item.value})`; + case "arrayIndex": + return `ArrayIndex:(${item.value})`; + case "cellAddress": + return `CellAddress:(${item.value.row}:${item.value.column})`; + case "calculator": + return "Calculator"; + } + } } // There might be a better place for this to go, nearer to the ASTNode type. @@ -191,6 +205,10 @@ export class SqValuePath { }); } + uid(): string { + return `${this.root}--${this.items.map((item) => item.uid()).join("--")}`; + } + // Checks if this SqValuePath completely contains all of the nodes in this other one. contains(smallerItem: SqValuePath) { if (this.root !== smallerItem.root) { From 061a924243884167117d9e9ee9d277fcc4d07efe Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Fri, 19 Jan 2024 21:18:18 -0800 Subject: [PATCH 09/36] Added UID for Paths --- .../SquiggleViewer/ViewerProvider.tsx | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index bb5f239f72..52e34c505b 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -51,10 +51,12 @@ const defaultLocalItemState: LocalItemState = { settings: {}, }; +type ValuePathUID = string; + class PathTreeNode { readonly tree: PathTree; - readonly fullPath: SqValuePath; - parent: PathTreeNode | undefined; + readonly path: SqValuePath; + readonly parent: PathTreeNode | undefined; children: PathTreeNode[] = []; constructor( @@ -64,24 +66,23 @@ class PathTreeNode { ) { this.parent = parent; this.tree = tree; - this.fullPath = path; + this.path = path; this.isEqual = this.isEqual.bind(this); } uid() { - return this.fullPath.uid(); + return this.path.uid(); } - - private isEqual(other: PathTreeNode) { - return this.fullPath.isEqual(other.fullPath); + isRoot() { + return this.path.isRoot(); } - isRoot() { - return this.fullPath.isRoot(); + private isEqual(other: PathTreeNode) { + return this.path.isEqual(other.path); } private isCollapsed() { - return this.tree.itemStore.getState(this.fullPath).collapsed; + return this.tree.isPathCollapsed(this.path); // This seems awkward, should find another way to deal with it. } private childrenAreVisible() { @@ -95,7 +96,7 @@ class PathTreeNode { addChild(path: SqValuePath): PathTreeNode | undefined { //We don't really need this alreadyExists check, as this normally is just called by PathTree.addNode, which already checks for existence, but seems safe. const alreadyExists = this.children.some((child) => - child.fullPath.isEqual(path) + child.path.isEqual(path) ); if (!alreadyExists) { const node = new PathTreeNode(path, this, this.tree); @@ -106,7 +107,7 @@ class PathTreeNode { removeChild(node: PathTreeNode) { this.children = this.children.filter( - (child) => !child.fullPath.isEqual(node.fullPath) + (child) => !child.path.isEqual(node.path) ); } @@ -115,6 +116,7 @@ class PathTreeNode { } private getParentIndex() { + //We could later optimize this by using the listIndex of arrayIndex nodes and the fact that dictKeys are sorted. const siblings = this.siblings(); return siblings.findIndex(this.isEqual); } @@ -169,14 +171,17 @@ class PathTreeNode { class PathTree { readonly root: PathTreeNode; - nodes: Map = new Map(); - values: Map = new Map(); // Maybe there's a better place this could go? - itemStore: ItemStore; + readonly nodes: Map = new Map(); + readonly values: Map = new Map(); // This could probably go to a better place + readonly isPathCollapsed: (path: SqValuePath) => boolean; - constructor(rootNote: SqValueWithContext, itemStore) { - this.root = new PathTreeNode(rootNote.context.path, undefined, this); - this._addNode(this.root, rootNote); - this.itemStore = itemStore; + constructor( + rootNode: SqValueWithContext, + getIsCollapsed: (path: SqValuePath) => boolean + ) { + this.root = new PathTreeNode(rootNode.context.path, undefined, this); + this._addNode(this.root, rootNode); + this.isPathCollapsed = getIsCollapsed; } private _addNode(node: PathTreeNode, value: SqValueWithContext) { @@ -242,16 +247,15 @@ function isElementInView(element: HTMLElement) { * Then we won't have to rely on `forceUpdate` for rerenders. */ class ItemStore { - state: Record = {}; - handles: Record = {}; + state: Record = {}; + handles: Record = {}; setState( path: SqValuePath, fn: (localItemState: LocalItemState) => LocalItemState ): void { - const pathString = path.uid(); - const newSettings = fn(this.state[pathString] || defaultLocalItemState); - this.state[pathString] = newSettings; + const newSettings = fn(this.state[path.uid()] || defaultLocalItemState); + this.state[path.uid()] = newSettings; } getState(path: SqValuePath): LocalItemState { @@ -411,7 +415,10 @@ export function useRegisterAsItemViewer( if (!pathTree) { if (!parent) { - const newPathTree = new PathTree(value, itemStore); + const newPathTree = new PathTree( + value, + (path) => itemStore.getState(path).collapsed + ); setPathTree(newPathTree); } } else if (parent) { @@ -610,7 +617,7 @@ export const InnerViewerProvider = forwardRef( case "ArrowDown": { const newItem = node?.children[0]; if (newItem) { - setSelected(newItem.fullPath); + setSelected(newItem.path); } break; } @@ -620,9 +627,9 @@ export const InnerViewerProvider = forwardRef( if (newItem.isRoot()) { setFocused(undefined); } else { - setFocused(newItem.fullPath); - setSelected(newItem.fullPath); - scrollToPath(newItem.fullPath); + setFocused(newItem.path); + setSelected(newItem.path); + scrollToPath(newItem.path); } } break; @@ -630,18 +637,18 @@ export const InnerViewerProvider = forwardRef( case "ArrowLeft": { const newItem = node?.prevSibling(); if (newItem) { - setFocused(newItem.fullPath); - setSelected(newItem.fullPath); - scrollToPath(newItem.fullPath); + setFocused(newItem.path); + setSelected(newItem.path); + scrollToPath(newItem.path); } break; } case "ArrowRight": { const newItem = node?.nextSibling(); if (newItem) { - setFocused(newItem.fullPath); - setSelected(newItem.fullPath); - scrollToPath(newItem.fullPath); + setFocused(newItem.path); + setSelected(newItem.path); + scrollToPath(newItem.path); } break; } @@ -662,7 +669,7 @@ export const InnerViewerProvider = forwardRef( case "ArrowDown": { const newItem = node?.next(); if (newItem) { - const newPath = newItem.fullPath; + const newPath = newItem.path; setSelected(newPath); scrollToPath(newPath); if (!itemStore.isInView(newPath)) { @@ -674,7 +681,7 @@ export const InnerViewerProvider = forwardRef( case "ArrowUp": { const newItem = node?.prev(); if (newItem) { - const newPath = newItem.fullPath; + const newPath = newItem.path; setSelected(newPath); scrollToPath(newPath); if (!itemStore.isInView(newPath)) { @@ -685,7 +692,7 @@ export const InnerViewerProvider = forwardRef( } case "ArrowLeft": { const newItem = node?.parent; - newItem && !newItem.isRoot() && setSelected(newItem.fullPath); + newItem && !newItem.isRoot() && setSelected(newItem.path); break; } case "ArrowRight": { From ac70297c3c9f32b1646f3021d762f9a061271d37 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Fri, 19 Jan 2024 23:18:26 -0800 Subject: [PATCH 10/36] Moving arrowActions out --- .../SquiggleViewer/ItemSettingsMenuItems.tsx | 2 +- .../SquiggleViewer/ViewerProvider.tsx | 264 ++++++++++-------- 2 files changed, 152 insertions(+), 114 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ItemSettingsMenuItems.tsx b/packages/components/src/components/SquiggleViewer/ItemSettingsMenuItems.tsx index 122096437a..c30fc876f5 100644 --- a/packages/components/src/components/SquiggleViewer/ItemSettingsMenuItems.tsx +++ b/packages/components/src/components/SquiggleViewer/ItemSettingsMenuItems.tsx @@ -67,7 +67,7 @@ const ItemSettingsModal: FC = ({ const { itemStore } = useContext(ViewerContext); const resetScroll = () => { - itemStore.scrollToPath(path); + itemStore.scrollViewerToPath(path); }; return ( diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 52e34c505b..a7238c6c81 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -334,7 +334,7 @@ class ItemStore { })); } - scrollToPath(path: SqValuePath) { + scrollViewerToPath(path: SqValuePath) { // setFocused(path); this.handles[path.uid()]?.element.scrollIntoView({ behavior: "instant", @@ -388,7 +388,7 @@ export function useViewerContext() { // `` calls this hook to register its handle in ``. // This allows us to do two things later: -// 1. Implement `store.scrollToPath`. +// 1. Implement `store.scrollViewerToPath`. // 2. Re-render individual item viewers on demand, for example on "Collapse Children" menu action. export function useRegisterAsItemViewer( path: SqValuePath, @@ -569,6 +569,141 @@ function isArrowEvent(str: string): str is ArrowEvent { ); } +function arrowActions({ + getNode, + setFocused, + setSelected, + scrollEditorToPath, + scrollViewerToPath, + itemStore, +}: { + getNode: (path: SqValuePath) => PathTreeNode | undefined; + setFocused: (value: SqValuePath | undefined) => void; + setSelected: (value: SqValuePath | undefined) => void; + scrollEditorToPath: (path: SqValuePath) => void; + scrollViewerToPath: (path: SqValuePath) => void; + itemStore: ItemStore; +}): ({ + event, + focused, + selected, +}: { + event: ArrowEvent; + focused?: SqValuePath; + selected?: SqValuePath; +}) => void { + function focusArrowEvent(event: ArrowEvent, focused: SqValuePath) { + const node = getNode(focused); + switch (event) { + case "ArrowDown": { + const newItem = node?.children[0]; + if (newItem) { + setSelected(newItem.path); + } + break; + } + case "ArrowUp": { + const newItem = node?.parent; + if (newItem) { + if (newItem.isRoot()) { + setFocused(undefined); + } else { + setFocused(newItem.path); + setSelected(newItem.path); + scrollEditorToPath(newItem.path); + } + } + break; + } + case "ArrowLeft": { + const newItem = node?.prevSibling(); + if (newItem) { + setFocused(newItem.path); + setSelected(newItem.path); + scrollEditorToPath(newItem.path); + } + break; + } + case "ArrowRight": { + const newItem = node?.nextSibling(); + if (newItem) { + setFocused(newItem.path); + setSelected(newItem.path); + scrollEditorToPath(newItem.path); + } + break; + } + case "Enter": { + setFocused(undefined); + break; + } + } + } + + function selectedUnfocusedArrowEvent( + event: ArrowEvent, + selected: SqValuePath + ) { + const node = getNode(selected); + switch (event) { + case "ArrowDown": { + const newItem = node?.next(); + if (newItem) { + const newPath = newItem.path; + setSelected(newPath); + scrollEditorToPath(newPath); + scrollViewerToPath(newPath); + } + break; + } + case "ArrowUp": { + const newItem = node?.prev(); + if (newItem) { + const newPath = newItem.path; + setSelected(newPath); + scrollEditorToPath(newPath); + scrollViewerToPath(newPath); + } + break; + } + case "ArrowLeft": { + const newItem = node?.parent; + newItem && !newItem.isRoot() && setSelected(newItem.path); + break; + } + case "ArrowRight": { + itemStore.setState(selected, (state) => ({ + ...state, + collapsed: !state?.collapsed, + })); + scrollViewerToPath(selected); + itemStore.forceUpdate(selected); + break; + } + case "Enter": { + setFocused(selected); + break; + } + } + } + + return ({ + event, + focused, + selected, + }: { + event: ArrowEvent; + focused?: SqValuePath; + selected?: SqValuePath; + }) => { + if (focused && selected && focused === selected) { + focusArrowEvent(event, focused); + } else if (selected) { + selectedUnfocusedArrowEvent(event, selected); + } + }; +} + export const InnerViewerProvider = forwardRef( ( { @@ -598,7 +733,7 @@ export const InnerViewerProvider = forwardRef( return merge({}, defaultPlaygroundSettings, playgroundSettings); }, [playgroundSettings]); - function scrollToPath(path: SqValuePath) { + function scrollEditorToPath(path: SqValuePath) { const value = pathTree?.getValue(path); const location = value?.context?.findLocation(); @@ -607,126 +742,29 @@ export const InnerViewerProvider = forwardRef( } } - function focusArrowEvent( - event: ArrowEvent, - pathTree: PathTree, - focused: SqValuePath - ) { - const node = pathTree.getNode(focused); - switch (event) { - case "ArrowDown": { - const newItem = node?.children[0]; - if (newItem) { - setSelected(newItem.path); - } - break; - } - case "ArrowUp": { - const newItem = node?.parent; - if (newItem) { - if (newItem.isRoot()) { - setFocused(undefined); - } else { - setFocused(newItem.path); - setSelected(newItem.path); - scrollToPath(newItem.path); - } - } - break; - } - case "ArrowLeft": { - const newItem = node?.prevSibling(); - if (newItem) { - setFocused(newItem.path); - setSelected(newItem.path); - scrollToPath(newItem.path); - } - break; - } - case "ArrowRight": { - const newItem = node?.nextSibling(); - if (newItem) { - setFocused(newItem.path); - setSelected(newItem.path); - scrollToPath(newItem.path); - } - break; - } - case "Enter": { - setFocused(undefined); - break; - } - } - } - - function selectedUnfocusedArrowEvent( - event: ArrowEvent, - pathTree: PathTree, - selected: SqValuePath - ) { - const node = pathTree.getNode(selected); - switch (event) { - case "ArrowDown": { - const newItem = node?.next(); - if (newItem) { - const newPath = newItem.path; - setSelected(newPath); - scrollToPath(newPath); - if (!itemStore.isInView(newPath)) { - itemStore.scrollToPath(newPath); - } - } - break; - } - case "ArrowUp": { - const newItem = node?.prev(); - if (newItem) { - const newPath = newItem.path; - setSelected(newPath); - scrollToPath(newPath); - if (!itemStore.isInView(newPath)) { - itemStore.scrollToPath(newPath); - } - } - break; - } - case "ArrowLeft": { - const newItem = node?.parent; - newItem && !newItem.isRoot() && setSelected(newItem.path); - break; + const arrowActionFn = arrowActions({ + setFocused, + setSelected, + getNode: (path) => pathTree?.getNode(path), + scrollEditorToPath, + scrollViewerToPath: (path) => { + if (!itemStore.isInView(path)) { + itemStore.scrollViewerToPath(path); } - case "ArrowRight": { - itemStore.setState(selected, (state) => ({ - ...state, - collapsed: !state?.collapsed, - })); - if (!itemStore.isInView(selected)) { - itemStore.scrollToPath(selected); - } - itemStore.forceUpdate(selected); - break; - } - case "Enter": { - setFocused(selected); - break; - } - } - } + }, + itemStore, + }); const handle: SquiggleViewerHandle = { viewValuePath(path: SqValuePath) { setSelected(path); - itemStore.scrollToPath(path); + itemStore.scrollViewerToPath(path); }, onKeyPress(stroke: string) { const arrowEvent = isArrowEvent(stroke) ? stroke : undefined; if (arrowEvent && pathTree) { - if (focused && selected && focused === selected) { - focusArrowEvent(arrowEvent, pathTree, focused); - } else if (selected) { - selectedUnfocusedArrowEvent(arrowEvent, pathTree, selected); - } + arrowActionFn({ focused, selected, event: arrowEvent }); } }, }; From d0eba5eaf4f55de89b21b3d8e5a90101a1b6bdd6 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Sat, 20 Jan 2024 01:17:56 -0800 Subject: [PATCH 11/36] Quick fix --- .../src/components/SquiggleViewer/ViewerProvider.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index a7238c6c81..90852e505b 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -185,13 +185,13 @@ class PathTree { } private _addNode(node: PathTreeNode, value: SqValueWithContext) { - this.nodes.set(node.toString(), node); - this.values.set(node.toString(), value); + this.nodes.set(node.uid(), node); + this.values.set(node.uid(), value); } private _removeNode(node: PathTreeNode) { - this.nodes.delete(node.toString()); - this.values.delete(node.toString()); + this.nodes.delete(node.uid()); + this.values.delete(node.uid()); node.parent?.removeChild(node); node.children.forEach((child) => this._removeNode(child)); } From 455769287ceae63edf8d2e69f049b24bfe38070b Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Sat, 20 Jan 2024 11:57:14 -0800 Subject: [PATCH 12/36] Keyboard nav works using focus state --- .../components/SquigglePlayground/index.tsx | 15 - .../SquiggleViewer/ValueWithContextViewer.tsx | 46 ++- .../SquiggleViewer/ViewerProvider.tsx | 362 ++++++++---------- .../stories/SquigglePlayground.stories.tsx | 1 - 4 files changed, 189 insertions(+), 235 deletions(-) diff --git a/packages/components/src/components/SquigglePlayground/index.tsx b/packages/components/src/components/SquigglePlayground/index.tsx index bd09622167..9974fb8574 100644 --- a/packages/components/src/components/SquigglePlayground/index.tsx +++ b/packages/components/src/components/SquigglePlayground/index.tsx @@ -175,21 +175,6 @@ export const SquigglePlayground: React.FC = (
); - useEffect(() => { - const handleKeyUp = (event: KeyboardEvent) => { - event.preventDefault(); - rightPanelRef.current?.onKeyPress(event.key as string); - }; - - // Attach the event listener - window.addEventListener("keydown", handleKeyUp); - - // Clean up the event listener - return () => { - window.removeEventListener("keydown", handleKeyUp); - }; - }, []); // Empty dependency array ensures this runs once on mount and on unmount - return ( = ({ const toggleCollapsed_ = useToggleCollapsed(); const focus = useFocus(); - const select = useSelect(); - const isSelected = useIsSelected(path); + const itemEvent = useItemEvent(path); + const focusedItemEvent = useFocusedItemEvent(path); const viewerType = useViewerType(); const { itemStore } = useViewerContext(); @@ -239,26 +240,45 @@ export const ValueWithContextViewer: FC = ({ }; const extraHeaderClasses = () => { - if (header === "large") { - return "mb-2"; - } else { - if (isSelected) { - return "bg-blue-100 hover:bg-blue-200"; - } - return "hover:bg-stone-100 rounded-sm"; - } + return ( + "hover:bg-stone-100 rounded-sm focus-visible:outline-none " + + (header === "large" + ? "focus:bg-blue-100 mb-2 px-0.5 py-1" + : "focus:bg-blue-200") + ); }; + const headerId = `header-${pathToShortName(path)}-${size}`; + + // useEffect(() => { + // if (size === "large") { + // const headerElement = ref.current?.querySelector(`#${headerId}`); + // if (headerElement instanceof HTMLElement) { + // headerElement.focus(); + // } + // } + // }, [size, headerId, ref]); + return (
{header !== "hide" && (
select(path)} + onKeyDown={(event) => { + // console.log("event.key", event.key); + if (isArrowEvent(event.key)) { + event.preventDefault(); + size === "large" + ? focusedItemEvent(event.key as string) + : itemEvent(event.key as string); + } + }} >
{collapsible && triangleToggle()} diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 90852e505b..d0dad9a6f5 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -29,7 +29,6 @@ type ViewerType = "normal" | "tooltip"; export type SquiggleViewerHandle = { viewValuePath(path: SqValuePath): void; - onKeyPress(stroke: string): void; }; type ItemHandle = { @@ -346,6 +345,19 @@ class ItemStore { } } +type ArrowEvent = + | "ArrowDown" + | "ArrowUp" + | "ArrowLeft" + | "ArrowRight" + | "Enter"; + +export function isArrowEvent(str: string): str is ArrowEvent { + return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( + str + ); +} + type ViewerContextShape = { // Note that we don't store `localItemState` itself in the context (that would cause rerenders of the entire tree on each settings update). // Instead, we keep `localItemState` in local state and notify the global context via `setLocalItemState` to pass them down the component tree again if it got rebuilt from scratch. @@ -355,8 +367,6 @@ type ViewerContextShape = { setPathTree: (value: PathTree | undefined) => void; focused: SqValuePath | undefined; setFocused: (value: SqValuePath | undefined) => void; - selected: SqValuePath | undefined; - setSelected: (value: SqValuePath | undefined) => void; editor?: CodeEditorHandle; itemStore: ItemStore; viewerType: ViewerType; @@ -370,14 +380,11 @@ export const ViewerContext = createContext({ setPathTree: () => undefined, focused: undefined, setFocused: () => undefined, - selected: undefined, - setSelected: () => undefined, editor: undefined, itemStore: new ItemStore(), viewerType: "normal", handle: { viewValuePath: () => {}, - onKeyPress: () => {}, }, initialized: false, }); @@ -504,35 +511,159 @@ export function useFocus() { }; } -export function useSelect() { - const { selected, setSelected } = useViewerContext(); - return (path: SqValuePath) => { - if (selected?.isEqual(path)) { - return; // nothing to do - } - if (path.isRoot()) { - setSelected(undefined); // selecting root nodes is not allowed - } else { - setSelected(path); - } - }; -} - export function useUnfocus() { const { setFocused } = useViewerContext(); return () => setFocused(undefined); } +function scrollEditorToPath( + path: SqValuePath, + pathTree: PathTree, + editor?: CodeEditorHandle +) { + const value = pathTree.getValue(path); + const location = value?.context?.findLocation(); + + if (location) { + editor?.scrollTo(location.start.offset); + } +} + +const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { + const header = itemStore.handles[path.uid()]?.element.querySelector("header"); + if (header) { + header.focus(); + } +}; + +const scrollViewerToPath = (path: SqValuePath, itemStore: ItemStore) => { + if (!itemStore.isInView(path)) { + itemStore.scrollViewerToPath(path); + } +}; + +export function useItemEvent(selected: SqValuePath) { + const { setFocused, itemStore, editor, pathTree } = useViewerContext(); + const getNode = (path: SqValuePath) => pathTree?.getNode(path); + + return (event: string) => { + if (isArrowEvent(event) && pathTree) { + const node = getNode(selected); + switch (event) { + case "ArrowDown": { + const newItem = node?.next(); + if (newItem) { + const newPath = newItem.path; + focusHeader(newPath, itemStore); + scrollViewerToPath(newPath, itemStore); + scrollEditorToPath(newPath, pathTree, editor); + } + break; + } + case "ArrowUp": { + const newItem = node?.prev(); + if (newItem) { + const newPath = newItem.path; + focusHeader(newPath, itemStore); + scrollViewerToPath(newPath, itemStore); + scrollEditorToPath(newPath, pathTree, editor); + } + break; + } + case "ArrowLeft": { + const newItem = node?.parent; + newItem && !newItem.isRoot() && focusHeader(newItem.path, itemStore); + break; + } + case "ArrowRight": { + itemStore.setState(selected, (state) => ({ + ...state, + collapsed: !state?.collapsed, + })); + itemStore.forceUpdate(selected); + scrollViewerToPath(selected, itemStore); + break; + } + case "Enter": { + setFocused(selected); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + break; + } + } + } + }; +} +export function useFocusedItemEvent(selected: SqValuePath) { + const { setFocused, itemStore, editor, pathTree } = useViewerContext(); + const getNode = (path: SqValuePath) => pathTree?.getNode(path); + + return (event: string) => { + if (isArrowEvent(event) && pathTree) { + const node = getNode(selected); + switch (event) { + case "ArrowDown": { + const newItem = node?.children[0]; + if (newItem) { + focusHeader(newItem.path, itemStore); + } + break; + } + case "ArrowUp": { + const newItem = node?.parent; + if (newItem) { + if (newItem.isRoot()) { + setFocused(undefined); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + } else { + const newPath = newItem.path; + setFocused(newPath); + setTimeout(() => { + focusHeader(newPath, itemStore); + scrollEditorToPath(newPath, pathTree, editor); + }, 1); + } + } + break; + } + case "ArrowLeft": { + const newPath = node?.prevSibling()?.path; + if (newPath) { + setFocused(newPath); + focusHeader(newPath, itemStore); + scrollEditorToPath(newPath, pathTree, editor); + } + break; + } + case "ArrowRight": { + const newPath = node?.nextSibling()?.path; + if (newPath) { + setFocused(newPath); + focusHeader(newPath, itemStore); + scrollEditorToPath(newPath, pathTree, editor); + } + break; + } + case "Enter": { + setFocused(undefined); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + break; + } + } + } + }; +} + export function useIsFocused(path: SqValuePath) { const { focused } = useViewerContext(); return focused?.isEqual(path); } -export function useIsSelected(path: SqValuePath) { - const { selected } = useViewerContext(); - return selected?.isEqual(path); -} - export function useMergedSettings(path: SqValuePath) { const { itemStore, globalSettings } = useViewerContext(); @@ -556,154 +687,6 @@ type Props = PropsWithChildren<{ viewerType?: ViewerType; }>; -type ArrowEvent = - | "ArrowDown" - | "ArrowUp" - | "ArrowLeft" - | "ArrowRight" - | "Enter"; - -function isArrowEvent(str: string): str is ArrowEvent { - return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( - str - ); -} - -function arrowActions({ - getNode, - setFocused, - setSelected, - scrollEditorToPath, - scrollViewerToPath, - itemStore, -}: { - getNode: (path: SqValuePath) => PathTreeNode | undefined; - setFocused: (value: SqValuePath | undefined) => void; - setSelected: (value: SqValuePath | undefined) => void; - scrollEditorToPath: (path: SqValuePath) => void; - scrollViewerToPath: (path: SqValuePath) => void; - itemStore: ItemStore; -}): ({ - event, - focused, - selected, -}: { - event: ArrowEvent; - focused?: SqValuePath; - selected?: SqValuePath; -}) => void { - function focusArrowEvent(event: ArrowEvent, focused: SqValuePath) { - const node = getNode(focused); - switch (event) { - case "ArrowDown": { - const newItem = node?.children[0]; - if (newItem) { - setSelected(newItem.path); - } - break; - } - case "ArrowUp": { - const newItem = node?.parent; - if (newItem) { - if (newItem.isRoot()) { - setFocused(undefined); - } else { - setFocused(newItem.path); - setSelected(newItem.path); - scrollEditorToPath(newItem.path); - } - } - break; - } - case "ArrowLeft": { - const newItem = node?.prevSibling(); - if (newItem) { - setFocused(newItem.path); - setSelected(newItem.path); - scrollEditorToPath(newItem.path); - } - break; - } - case "ArrowRight": { - const newItem = node?.nextSibling(); - if (newItem) { - setFocused(newItem.path); - setSelected(newItem.path); - scrollEditorToPath(newItem.path); - } - break; - } - case "Enter": { - setFocused(undefined); - break; - } - } - } - - function selectedUnfocusedArrowEvent( - event: ArrowEvent, - selected: SqValuePath - ) { - const node = getNode(selected); - switch (event) { - case "ArrowDown": { - const newItem = node?.next(); - if (newItem) { - const newPath = newItem.path; - setSelected(newPath); - scrollEditorToPath(newPath); - scrollViewerToPath(newPath); - } - break; - } - case "ArrowUp": { - const newItem = node?.prev(); - if (newItem) { - const newPath = newItem.path; - setSelected(newPath); - scrollEditorToPath(newPath); - scrollViewerToPath(newPath); - } - break; - } - case "ArrowLeft": { - const newItem = node?.parent; - newItem && !newItem.isRoot() && setSelected(newItem.path); - break; - } - case "ArrowRight": { - itemStore.setState(selected, (state) => ({ - ...state, - collapsed: !state?.collapsed, - })); - scrollViewerToPath(selected); - itemStore.forceUpdate(selected); - break; - } - case "Enter": { - setFocused(selected); - break; - } - } - } - - return ({ - event, - focused, - selected, - }: { - event: ArrowEvent; - focused?: SqValuePath; - selected?: SqValuePath; - }) => { - if (focused && selected && focused === selected) { - focusArrowEvent(event, focused); - } else if (selected) { - selectedUnfocusedArrowEvent(event, selected); - } - }; -} - export const InnerViewerProvider = forwardRef( ( { @@ -726,47 +709,16 @@ export const InnerViewerProvider = forwardRef( ); const [focused, setFocused] = useState(); - const [selected, setSelected] = useState(); const [pathTree, setPathTree] = useState(); const globalSettings = useMemo(() => { return merge({}, defaultPlaygroundSettings, playgroundSettings); }, [playgroundSettings]); - function scrollEditorToPath(path: SqValuePath) { - const value = pathTree?.getValue(path); - const location = value?.context?.findLocation(); - - if (location) { - editor?.scrollTo(location.start.offset); - } - } - - const arrowActionFn = arrowActions({ - setFocused, - setSelected, - getNode: (path) => pathTree?.getNode(path), - scrollEditorToPath, - scrollViewerToPath: (path) => { - if (!itemStore.isInView(path)) { - itemStore.scrollViewerToPath(path); - } - }, - itemStore, - }); - const handle: SquiggleViewerHandle = { viewValuePath(path: SqValuePath) { - setSelected(path); itemStore.scrollViewerToPath(path); }, - onKeyPress(stroke: string) { - const arrowEvent = isArrowEvent(stroke) ? stroke : undefined; - - if (arrowEvent && pathTree) { - arrowActionFn({ focused, selected, event: arrowEvent }); - } - }, }; useImperativeHandle(ref, () => handle); @@ -778,8 +730,6 @@ export const InnerViewerProvider = forwardRef( editor, focused, setFocused, - selected, - setSelected, pathTree, setPathTree, itemStore, diff --git a/packages/components/src/stories/SquigglePlayground.stories.tsx b/packages/components/src/stories/SquigglePlayground.stories.tsx index dcd6efc209..c62ccb7dda 100644 --- a/packages/components/src/stories/SquigglePlayground.stories.tsx +++ b/packages/components/src/stories/SquigglePlayground.stories.tsx @@ -166,7 +166,6 @@ export const ManyTypes: Story = { defaultCode: `varNum = 3333 varBool = true varString = "This is a long string" -varVoid = () varArray = [1,2,3] varLambda = {|e| "Test"} From 7ba78b8d06bf20c5e69dd5c94a1782d92b479cde Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Sat, 20 Jan 2024 14:30:08 -0800 Subject: [PATCH 13/36] Focus-cleanup --- .../components/SquiggleViewer/ValueViewer.tsx | 8 +- .../SquiggleViewer/ValueWithContextViewer.tsx | 28 ++--- .../SquiggleViewer/ViewerProvider.tsx | 101 +++++++----------- .../src/widgets/TableChartWidget.tsx | 4 +- 4 files changed, 61 insertions(+), 80 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx index 065f534b70..71efa15648 100644 --- a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx @@ -16,5 +16,11 @@ export const ValueViewer: React.FC = ({ value, ...rest }) => { return ; } - return ; + return ( + + ); }; diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 6fcbba4190..8e6f2f90e2 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -2,7 +2,7 @@ import "../../widgets/index.js"; import { clsx } from "clsx"; -import { FC, PropsWithChildren, useMemo } from "react"; +import { FC, PropsWithChildren, useEffect, useMemo } from "react"; import { SqValue } from "@quri/squiggle-lang"; import { CommentIcon, TextTooltip } from "@quri/ui"; @@ -26,6 +26,7 @@ import { useItemEvent, useMergedSettings, useRegisterAsItemViewer, + useScrollToEditorPath, useToggleCollapsed, useViewerContext, useViewerType, @@ -124,8 +125,10 @@ export const ValueWithContextViewer: FC = ({ const itemEvent = useItemEvent(path); const focusedItemEvent = useFocusedItemEvent(path); const viewerType = useViewerType(); + const scrollEditorToPath = useScrollToEditorPath(path); - const { itemStore } = useViewerContext(); + const { itemStore, focused } = useViewerContext(); + const isFocused = focused?.isEqual(path); const itemState = itemStore.getStateOrInitialize(value); const isRoot = path.isRoot(); @@ -248,30 +251,27 @@ export const ValueWithContextViewer: FC = ({ ); }; - const headerId = `header-${pathToShortName(path)}-${size}`; - - // useEffect(() => { - // if (size === "large") { - // const headerElement = ref.current?.querySelector(`#${headerId}`); - // if (headerElement instanceof HTMLElement) { - // headerElement.focus(); - // } - // } - // }, [size, headerId, ref]); + useEffect(() => { + const header = ref.current?.querySelector("header"); + if (isFocused && !isRoot) { + header?.focus(); + } + }, []); return (
{header !== "hide" && (
{ + scrollEditorToPath(); + }} onKeyDown={(event) => { - // console.log("event.key", event.key); if (isArrowEvent(event.key)) { event.preventDefault(); size === "large" diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index d0dad9a6f5..ac7149fd38 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -451,14 +451,18 @@ export function useSetLocalItemState() { }; } +function toggleCollapsed(itemStore: ItemStore, path: SqValuePath) { + itemStore.setState(path, (state) => ({ + ...state, + collapsed: !state?.collapsed, + })); + itemStore.forceUpdate(path); +} + export function useToggleCollapsed() { const { itemStore } = useViewerContext(); return (path: SqValuePath) => { - itemStore.setState(path, (state) => ({ - ...state, - collapsed: !state?.collapsed, - })); - itemStore.forceUpdate(path); + toggleCollapsed(itemStore, path); }; } @@ -516,17 +520,18 @@ export function useUnfocus() { return () => setFocused(undefined); } -function scrollEditorToPath( - path: SqValuePath, - pathTree: PathTree, - editor?: CodeEditorHandle -) { - const value = pathTree.getValue(path); - const location = value?.context?.findLocation(); +export function useScrollToEditorPath(path: SqValuePath) { + const { pathTree, editor } = useViewerContext(); + return () => { + if (pathTree && editor) { + const value = pathTree.getValue(path); + const location = value?.context?.findLocation(); - if (location) { - editor?.scrollTo(location.start.offset); - } + if (location) { + editor?.scrollTo(location.start.offset); + } + } + }; } const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { @@ -536,14 +541,8 @@ const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { } }; -const scrollViewerToPath = (path: SqValuePath, itemStore: ItemStore) => { - if (!itemStore.isInView(path)) { - itemStore.scrollViewerToPath(path); - } -}; - export function useItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, editor, pathTree } = useViewerContext(); + const { setFocused, itemStore, pathTree } = useViewerContext(); const getNode = (path: SqValuePath) => pathTree?.getNode(path); return (event: string) => { @@ -551,23 +550,13 @@ export function useItemEvent(selected: SqValuePath) { const node = getNode(selected); switch (event) { case "ArrowDown": { - const newItem = node?.next(); - if (newItem) { - const newPath = newItem.path; - focusHeader(newPath, itemStore); - scrollViewerToPath(newPath, itemStore); - scrollEditorToPath(newPath, pathTree, editor); - } + const newPath = node?.next()?.path; + newPath && focusHeader(newPath, itemStore); break; } case "ArrowUp": { - const newItem = node?.prev(); - if (newItem) { - const newPath = newItem.path; - focusHeader(newPath, itemStore); - scrollViewerToPath(newPath, itemStore); - scrollEditorToPath(newPath, pathTree, editor); - } + const newPath = node?.prev()?.path; + newPath && focusHeader(newPath, itemStore); break; } case "ArrowLeft": { @@ -576,19 +565,11 @@ export function useItemEvent(selected: SqValuePath) { break; } case "ArrowRight": { - itemStore.setState(selected, (state) => ({ - ...state, - collapsed: !state?.collapsed, - })); - itemStore.forceUpdate(selected); - scrollViewerToPath(selected, itemStore); + toggleCollapsed(itemStore, selected); break; } case "Enter": { setFocused(selected); - setTimeout(() => { - focusHeader(selected, itemStore); - }, 1); break; } } @@ -596,9 +577,16 @@ export function useItemEvent(selected: SqValuePath) { }; } export function useFocusedItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, editor, pathTree } = useViewerContext(); + const { setFocused, itemStore, pathTree } = useViewerContext(); const getNode = (path: SqValuePath) => pathTree?.getNode(path); + function resetToRoot() { + setFocused(undefined); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + } + return (event: string) => { if (isArrowEvent(event) && pathTree) { const node = getNode(selected); @@ -614,17 +602,9 @@ export function useFocusedItemEvent(selected: SqValuePath) { const newItem = node?.parent; if (newItem) { if (newItem.isRoot()) { - setFocused(undefined); - setTimeout(() => { - focusHeader(selected, itemStore); - }, 1); + resetToRoot(); } else { - const newPath = newItem.path; - setFocused(newPath); - setTimeout(() => { - focusHeader(newPath, itemStore); - scrollEditorToPath(newPath, pathTree, editor); - }, 1); + setFocused(newItem.path); } } break; @@ -633,8 +613,6 @@ export function useFocusedItemEvent(selected: SqValuePath) { const newPath = node?.prevSibling()?.path; if (newPath) { setFocused(newPath); - focusHeader(newPath, itemStore); - scrollEditorToPath(newPath, pathTree, editor); } break; } @@ -642,16 +620,11 @@ export function useFocusedItemEvent(selected: SqValuePath) { const newPath = node?.nextSibling()?.path; if (newPath) { setFocused(newPath); - focusHeader(newPath, itemStore); - scrollEditorToPath(newPath, pathTree, editor); } break; } case "Enter": { - setFocused(undefined); - setTimeout(() => { - focusHeader(selected, itemStore); - }, 1); + resetToRoot(); break; } } diff --git a/packages/components/src/widgets/TableChartWidget.tsx b/packages/components/src/widgets/TableChartWidget.tsx index b5a2ac0cd9..6c5c266f42 100644 --- a/packages/components/src/widgets/TableChartWidget.tsx +++ b/packages/components/src/widgets/TableChartWidget.tsx @@ -86,9 +86,11 @@ widgetRegistry.register("TableChart", { {row.map((item, k) => ( {showItem(item, adjustedSettings)} From d7755302956bae1838f84b1d5c07bd9e83a81598 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Sun, 21 Jan 2024 19:34:40 -0800 Subject: [PATCH 14/36] Minor fix --- .../src/components/SquiggleViewer/ValueWithContextViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 0da487c63d..b113b111e3 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -263,7 +263,7 @@ export const ValueWithContextViewer: FC = ({
{header !== "hide" && (
Date: Mon, 22 Jan 2024 09:23:57 -0800 Subject: [PATCH 15/36] Minor improvements for TableChartWidget --- .../SquiggleViewer/ValueWithContextViewer.tsx | 6 +++--- .../components/src/widgets/TableChartWidget.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index b113b111e3..762b4a5e5b 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -167,7 +167,7 @@ export const ValueWithContextViewer: FC = ({
@@ -246,8 +246,8 @@ export const ValueWithContextViewer: FC = ({ return ( "hover:bg-stone-100 rounded-sm focus-visible:outline-none " + (header === "large" - ? "focus:bg-blue-100 mb-2 px-0.5 py-1" - : "focus:bg-blue-200") + ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" + : "focus:bg-indigo-100") ); }; diff --git a/packages/components/src/widgets/TableChartWidget.tsx b/packages/components/src/widgets/TableChartWidget.tsx index 6c5c266f42..1a6128a3e3 100644 --- a/packages/components/src/widgets/TableChartWidget.tsx +++ b/packages/components/src/widgets/TableChartWidget.tsx @@ -5,6 +5,7 @@ import { TableCellsIcon } from "@quri/ui"; import { PlaygroundSettings } from "../components/PlaygroundSettings.js"; import { SquiggleValueChart } from "../components/SquiggleViewer/SquiggleValueChart.js"; +import { useFocus } from "../components/SquiggleViewer/ViewerProvider.js"; import { valueHasContext } from "../lib/utility.js"; import { widgetRegistry } from "./registry.js"; @@ -22,7 +23,7 @@ widgetRegistry.register("TableChart", { Chart: (valueWithContext, settings) => { const environment = valueWithContext.context.project.getEnvironment(); const value = valueWithContext.value; - + const focus = useFocus(); const rowsAndColumns = value.items(environment); const columnNames = value.columnNames; const hasColumnNames = columnNames.filter((name) => !!name).length > 0; @@ -60,7 +61,7 @@ widgetRegistry.register("TableChart", { return (
-
+
(
{ + if (event.key === "Enter" && item.ok) { + event.preventDefault(); + const path = item.value.context?.path; + path && focus(path); + } + }} className={clsx( "px-1 overflow-hidden", k !== 0 && "border-stone-100 border-l", From c635466b5f06834f0450163c7497aac8ed88f6b2 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Mon, 22 Jan 2024 20:39:27 -0800 Subject: [PATCH 16/36] Refactored pathTree into SqViewNode.tsx --- .../CodeEditor/useTooltipsExtension.tsx | 1 + .../components/SquiggleOutputViewer/index.tsx | 1 + .../components/SquiggleViewer/SqViewNode.tsx | 177 +++++++++++ .../SquiggleViewer/ValueWithContextViewer.tsx | 2 +- .../SquiggleViewer/ViewerProvider.tsx | 289 +++++------------- .../src/components/SquiggleViewer/index.tsx | 1 + .../src/components/SquiggleViewer/utils.ts | 23 +- .../squiggle-lang/src/public/SqValuePath.ts | 11 + 8 files changed, 273 insertions(+), 232 deletions(-) create mode 100644 packages/components/src/components/SquiggleViewer/SqViewNode.tsx diff --git a/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx b/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx index ddf8b42cf9..0ce5e7e945 100644 --- a/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx +++ b/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx @@ -48,6 +48,7 @@ const ValueTooltip: FC<{ value: SqValue; view: EditorView }> = ({ diff --git a/packages/components/src/components/SquiggleOutputViewer/index.tsx b/packages/components/src/components/SquiggleOutputViewer/index.tsx index f74d95c084..b0c948e5ca 100644 --- a/packages/components/src/components/SquiggleOutputViewer/index.tsx +++ b/packages/components/src/components/SquiggleOutputViewer/index.tsx @@ -51,6 +51,7 @@ export const SquiggleOutputViewer = forwardRef( partialPlaygroundSettings={settings} editor={editor} ref={viewerRef} + value={(output.ok && output.value.bindings.asValue()) || undefined} > } diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx new file mode 100644 index 0000000000..f24f7f3d60 --- /dev/null +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -0,0 +1,177 @@ +import { SqValue, SqValuePath } from "@quri/squiggle-lang"; + +export function getChildrenValues(value: SqValue): SqValue[] { + switch (value.tag) { + case "Array": + return value.value.getValues(); + case "Dict": + return value.value.entries().map((a) => a[1]); + default: { + return []; + } + } +} + +type TraverseCalculatorEdge = (path: SqValuePath) => SqValue | undefined; + +class SqValueNode { + constructor( + public root: SqValue, + public path: SqValuePath, + public traverseCalculatorEdge: TraverseCalculatorEdge + ) {} + + uid() { + return this.path.uid(); + } + + isEqual = (other: SqValueNode) => { + return this.uid() === other.uid(); + }; + + sqValue(): SqValue | undefined { + return this.root.getSubvalueByPath(this.path, this.traverseCalculatorEdge); + } + + parent() { + const parentPath = this.path.parent(); + if (!parentPath) { + return undefined; + } + return parentPath + ? new SqValueNode(this.root, parentPath, this.traverseCalculatorEdge) + : undefined; + } + + children() { + const value = this.sqValue(); + if (!value) { + return []; + } + return getChildrenValues(value) + .map((childValue) => { + const path = childValue.context?.path; + return path + ? new SqValueNode(this.root, path, this.traverseCalculatorEdge) + : undefined; + }) + .filter((a) => a !== undefined) as SqValueNode[]; + } + + lastChild(): SqValueNode | undefined { + return this.children().at(-1); + } + + siblings() { + return this.parent()?.children() || []; + } + + getParentIndex() { + const siblings = this.siblings(); + return siblings.findIndex(this.isEqual); + } +} + +type GetIsCollapsed = (path: SqValuePath) => boolean; +type Params = { getIsCollapsed: GetIsCollapsed }; + +export class SqViewNode { + constructor( + public node: SqValueNode, + public params: Params + ) { + this.make = this.make.bind(this); + } + + static make( + root: SqValue, + path: SqValuePath, + traverseCalculatorEdge: TraverseCalculatorEdge, + getIsCollapsed: GetIsCollapsed + ) { + const node = new SqValueNode(root, path, traverseCalculatorEdge); + return new SqViewNode(node, { getIsCollapsed }); + } + + make(node: SqValueNode) { + return new SqViewNode(node, this.params); + } + + value(): SqValue | undefined { + return this.node.sqValue(); + } + + isRoot() { + return this.node.path.isRoot(); + } + + parent() { + const parent = this.node.parent(); + return parent ? this.make(parent) : undefined; + } + + children() { + return this.node.children().map(this.make); + } + lastChild() { + return this.make(this.node.lastChild()!); + } + siblings() { + return this.node.siblings().map(this.make); + } + + prevSibling() { + const index = this.node.getParentIndex(); + const isRootOrError = index === -1; + const isFirstSibling = index === 0; + if (isRootOrError || isFirstSibling) { + return undefined; + } + return this.siblings()[index - 1]; + } + + nextSibling() { + const index = this.node.getParentIndex(); + const isRootOrError = index === -1; + const isLastSibling = index === this.node.siblings().length - 1; + if (isRootOrError || isLastSibling) { + return undefined; + } + return this.siblings()[index + 1]; + } + + private isCollapsed() { + return this.params.getIsCollapsed(this.node.path); + } + + private childrenAreVisible() { + return !this.isCollapsed(); + } + + private lastVisibleSubChild(): SqViewNode | undefined { + if (this.children.length > 0 && this.childrenAreVisible()) { + const lastChild = this.lastChild(); + return lastChild?.lastVisibleSubChild() || lastChild; + } else { + return this; + } + } + + private nextAvailableSibling(): SqViewNode | undefined { + return this.nextSibling() || this.parent(); + } + + next(): SqViewNode | undefined { + return this.children().length > 0 && !this.isCollapsed() + ? this.children()[0] + : this.nextAvailableSibling(); + } + + prev(): SqViewNode | undefined { + const prevSibling = this.prevSibling(); + if (!prevSibling) { + return this.parent(); + } + return prevSibling.lastVisibleSubChild(); + } +} diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 762b4a5e5b..a5247a1ac2 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -145,7 +145,7 @@ export const ValueWithContextViewer: FC = ({ toggleCollapsed_(path); }; - const ref = useRegisterAsItemViewer(path, value, parentValue); + const ref = useRegisterAsItemViewer(path); // TODO - check that we're not in a situation where `isOpen` is false and `header` is hidden? // In that case, the output would look broken (empty). diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index ac7149fd38..31127dc257 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -23,10 +23,31 @@ import { PartialPlaygroundSettings, PlaygroundSettings, } from "../PlaygroundSettings.js"; -import { getChildrenValues, shouldBeginCollapsed } from "./utils.js"; +import { SqViewNode } from "./SqViewNode.js"; +import { + getChildrenValues, + shouldBeginCollapsed, + traverseCalculatorEdge, +} from "./utils.js"; type ViewerType = "normal" | "tooltip"; +function findNode( + root: SqValue | undefined, + path: SqValuePath, + itemStore: ItemStore +) { + if (!root || !path) { + return; + } + return SqViewNode.make( + root, + path, + traverseCalculatorEdge(itemStore), + (path) => itemStore.getState(path).collapsed + ); +} + export type SquiggleViewerHandle = { viewValuePath(path: SqValuePath): void; }; @@ -52,175 +73,6 @@ const defaultLocalItemState: LocalItemState = { type ValuePathUID = string; -class PathTreeNode { - readonly tree: PathTree; - readonly path: SqValuePath; - readonly parent: PathTreeNode | undefined; - children: PathTreeNode[] = []; - - constructor( - path: SqValuePath, - parent: PathTreeNode | undefined, - tree: PathTree - ) { - this.parent = parent; - this.tree = tree; - this.path = path; - this.isEqual = this.isEqual.bind(this); - } - - uid() { - return this.path.uid(); - } - isRoot() { - return this.path.isRoot(); - } - - private isEqual(other: PathTreeNode) { - return this.path.isEqual(other.path); - } - - private isCollapsed() { - return this.tree.isPathCollapsed(this.path); // This seems awkward, should find another way to deal with it. - } - - private childrenAreVisible() { - return !this.isCollapsed(); - } - - lastChild(): PathTreeNode | undefined { - return this.children[this.children.length - 1]; - } - - addChild(path: SqValuePath): PathTreeNode | undefined { - //We don't really need this alreadyExists check, as this normally is just called by PathTree.addNode, which already checks for existence, but seems safe. - const alreadyExists = this.children.some((child) => - child.path.isEqual(path) - ); - if (!alreadyExists) { - const node = new PathTreeNode(path, this, this.tree); - this.children.push(node); - return node; - } - } - - removeChild(node: PathTreeNode) { - this.children = this.children.filter( - (child) => !child.path.isEqual(node.path) - ); - } - - siblings(): PathTreeNode[] { - return this.parent?.children || []; - } - - private getParentIndex() { - //We could later optimize this by using the listIndex of arrayIndex nodes and the fact that dictKeys are sorted. - const siblings = this.siblings(); - return siblings.findIndex(this.isEqual); - } - - prevSibling() { - const index = this.getParentIndex(); - const isRootOrError = index === -1; - const isFirstSibling = index === 0; - if (isRootOrError || isFirstSibling) { - return undefined; - } - return this.siblings()[index - 1]; - } - - nextSibling() { - const index = this.getParentIndex(); - const isRootOrError = index === -1; - const isLastSibling = index === this.siblings().length - 1; - if (isRootOrError || isLastSibling) { - return undefined; - } - return this.siblings()[index + 1]; - } - - private lastVisibleSubChild(): PathTreeNode | undefined { - if (this.children.length > 0 && this.childrenAreVisible()) { - const lastChild = this.lastChild(); - return lastChild?.lastVisibleSubChild() || lastChild; - } else { - return this; - } - } - - private nextAvailableSibling(): PathTreeNode | undefined { - return this.nextSibling() || this.parent?.nextAvailableSibling(); - } - - next(): PathTreeNode | undefined { - return this.children.length > 0 && !this.isCollapsed() - ? this.children[0] - : this.nextAvailableSibling(); - } - - prev(): PathTreeNode | undefined { - const prevSibling = this.prevSibling(); - if (!prevSibling) { - return this.parent; - } - return prevSibling.lastVisibleSubChild(); - } -} - -class PathTree { - readonly root: PathTreeNode; - readonly nodes: Map = new Map(); - readonly values: Map = new Map(); // This could probably go to a better place - readonly isPathCollapsed: (path: SqValuePath) => boolean; - - constructor( - rootNode: SqValueWithContext, - getIsCollapsed: (path: SqValuePath) => boolean - ) { - this.root = new PathTreeNode(rootNode.context.path, undefined, this); - this._addNode(this.root, rootNode); - this.isPathCollapsed = getIsCollapsed; - } - - private _addNode(node: PathTreeNode, value: SqValueWithContext) { - this.nodes.set(node.uid(), node); - this.values.set(node.uid(), value); - } - - private _removeNode(node: PathTreeNode) { - this.nodes.delete(node.uid()); - this.values.delete(node.uid()); - node.parent?.removeChild(node); - node.children.forEach((child) => this._removeNode(child)); - } - - getNode(path: SqValuePath): PathTreeNode | undefined { - return this.nodes.get(path.uid()); - } - - getValue(path: SqValuePath): SqValueWithContext | undefined { - return this.values.get(path.uid()); - } - - removeNode(value: SqValueWithContext): void { - const node = this.getNode(value.context.path); - if (node) { - this._removeNode(node); - } - } - - addNode(child: SqValueWithContext, parent: SqValueWithContext) { - if (!this.getNode(child.context.path)) { - const parentNode = this.getNode(parent.context.path); - if (parentNode) { - const newNode = parentNode.addChild(child.context.path); - newNode && this._addNode(newNode, child); - } - } - } -} - function isElementInView(element: HTMLElement) { const elementRect = element.getBoundingClientRect(); const container = document.querySelector( @@ -245,7 +97,7 @@ function isElementInView(element: HTMLElement) { * Note: this class is currently used as a primary source of truth. Should we use it as cache only, and store the state in React state instead? * Then we won't have to rely on `forceUpdate` for rerenders. */ -class ItemStore { +export class ItemStore { state: Record = {}; handles: Record = {}; @@ -363,8 +215,6 @@ type ViewerContextShape = { // Instead, we keep `localItemState` in local state and notify the global context via `setLocalItemState` to pass them down the component tree again if it got rebuilt from scratch. // See ./SquiggleViewer.tsx and ./ValueWithContextViewer.tsx for other implementation details on this. globalSettings: PlaygroundSettings; - pathTree: PathTree | undefined; - setPathTree: (value: PathTree | undefined) => void; focused: SqValuePath | undefined; setFocused: (value: SqValuePath | undefined) => void; editor?: CodeEditorHandle; @@ -372,12 +222,12 @@ type ViewerContextShape = { viewerType: ViewerType; initialized: boolean; handle: SquiggleViewerHandle; + rootValue?: SqValueWithContext; + findNode: (path: SqValuePath) => SqViewNode | undefined; }; export const ViewerContext = createContext({ globalSettings: defaultPlaygroundSettings, - pathTree: undefined, - setPathTree: () => undefined, focused: undefined, setFocused: () => undefined, editor: undefined, @@ -387,6 +237,8 @@ export const ViewerContext = createContext({ viewValuePath: () => {}, }, initialized: false, + rootValue: undefined, + findNode: () => undefined, }); export function useViewerContext() { @@ -397,13 +249,9 @@ export function useViewerContext() { // This allows us to do two things later: // 1. Implement `store.scrollViewerToPath`. // 2. Re-render individual item viewers on demand, for example on "Collapse Children" menu action. -export function useRegisterAsItemViewer( - path: SqValuePath, - value: SqValueWithContext, - parent: SqValue | undefined -) { +export function useRegisterAsItemViewer(path: SqValuePath) { const ref = useRef(null); - const { itemStore, pathTree, setPathTree } = useViewerContext(); + const { itemStore } = useViewerContext(); /** * Since `ViewerContext` doesn't store settings, this component won't rerender when `setSettings` is called. @@ -420,23 +268,8 @@ export function useRegisterAsItemViewer( itemStore.registerItemHandle(path, { element, forceUpdate }); - if (!pathTree) { - if (!parent) { - const newPathTree = new PathTree( - value, - (path) => itemStore.getState(path).collapsed - ); - setPathTree(newPathTree); - } - } else if (parent) { - if (valueHasContext(parent)) { - pathTree.addNode(value, parent); - } - } - return () => { itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often - // pathTree?.removeNode(value); }; }); @@ -521,10 +354,10 @@ export function useUnfocus() { } export function useScrollToEditorPath(path: SqValuePath) { - const { pathTree, editor } = useViewerContext(); + const { editor, findNode } = useViewerContext(); return () => { - if (pathTree && editor) { - const value = pathTree.getValue(path); + if (editor) { + const value = findNode(path)?.value(); const location = value?.context?.findLocation(); if (location) { @@ -542,26 +375,31 @@ const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { }; export function useItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, pathTree } = useViewerContext(); - const getNode = (path: SqValuePath) => pathTree?.getNode(path); + const { setFocused, itemStore, findNode } = useViewerContext(); return (event: string) => { - if (isArrowEvent(event) && pathTree) { - const node = getNode(selected); + if (isArrowEvent(event)) { + const myNode = findNode(selected); + if (!myNode) { + return; + } + switch (event) { case "ArrowDown": { - const newPath = node?.next()?.path; + const newPath = myNode.next()?.node.path; newPath && focusHeader(newPath, itemStore); break; } case "ArrowUp": { - const newPath = node?.prev()?.path; + const newPath = myNode.prev()?.node.path; newPath && focusHeader(newPath, itemStore); break; } case "ArrowLeft": { - const newItem = node?.parent; - newItem && !newItem.isRoot() && focusHeader(newItem.path, itemStore); + const newItem = myNode.parent(); + newItem && + !newItem.isRoot() && + focusHeader(newItem.node.path, itemStore); break; } case "ArrowRight": { @@ -576,9 +414,9 @@ export function useItemEvent(selected: SqValuePath) { } }; } + export function useFocusedItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, pathTree } = useViewerContext(); - const getNode = (path: SqValuePath) => pathTree?.getNode(path); + const { setFocused, itemStore, findNode } = useViewerContext(); function resetToRoot() { setFocused(undefined); @@ -588,36 +426,40 @@ export function useFocusedItemEvent(selected: SqValuePath) { } return (event: string) => { - if (isArrowEvent(event) && pathTree) { - const node = getNode(selected); + const myNode = findNode(selected); + if (!myNode) { + return; + } + + if (isArrowEvent(event)) { switch (event) { case "ArrowDown": { - const newItem = node?.children[0]; + const newItem = myNode.children()[0]; if (newItem) { - focusHeader(newItem.path, itemStore); + focusHeader(newItem.node.path, itemStore); } break; } case "ArrowUp": { - const newItem = node?.parent; + const newItem = myNode.parent(); if (newItem) { if (newItem.isRoot()) { resetToRoot(); } else { - setFocused(newItem.path); + setFocused(newItem.node.path); } } break; } case "ArrowLeft": { - const newPath = node?.prevSibling()?.path; + const newPath = myNode.prevSibling()?.node.path; if (newPath) { setFocused(newPath); } break; } case "ArrowRight": { - const newPath = node?.nextSibling()?.path; + const newPath = myNode.nextSibling()?.node.path; if (newPath) { setFocused(newPath); } @@ -658,6 +500,7 @@ type Props = PropsWithChildren<{ partialPlaygroundSettings: PartialPlaygroundSettings; editor?: CodeEditorHandle; viewerType?: ViewerType; + value: SqValue | undefined; }>; export const InnerViewerProvider = forwardRef( @@ -666,6 +509,7 @@ export const InnerViewerProvider = forwardRef( partialPlaygroundSettings: unstablePlaygroundSettings, editor, viewerType = "normal", + value, children, }, ref @@ -682,7 +526,6 @@ export const InnerViewerProvider = forwardRef( ); const [focused, setFocused] = useState(); - const [pathTree, setPathTree] = useState(); const globalSettings = useMemo(() => { return merge({}, defaultPlaygroundSettings, playgroundSettings); @@ -696,19 +539,25 @@ export const InnerViewerProvider = forwardRef( useImperativeHandle(ref, () => handle); + const _value = value + ? valueHasContext(value) + ? value + : undefined + : undefined; + return ( findNode(_value, path, itemStore), }} > {children} diff --git a/packages/components/src/components/SquiggleViewer/index.tsx b/packages/components/src/components/SquiggleViewer/index.tsx index 2fbc240fb2..f37e9c0473 100644 --- a/packages/components/src/components/SquiggleViewer/index.tsx +++ b/packages/components/src/components/SquiggleViewer/index.tsx @@ -113,6 +113,7 @@ const component = forwardRef( partialPlaygroundSettings={partialPlaygroundSettings} editor={editor} ref={ref} + value={value} > diff --git a/packages/components/src/components/SquiggleViewer/utils.ts b/packages/components/src/components/SquiggleViewer/utils.ts index 8877db7609..9a57a2b411 100644 --- a/packages/components/src/components/SquiggleViewer/utils.ts +++ b/packages/components/src/components/SquiggleViewer/utils.ts @@ -2,7 +2,7 @@ import { SqDict, SqValue, SqValuePath } from "@quri/squiggle-lang"; import { SHORT_STRING_LENGTH } from "../../lib/constants.js"; import { SqValueWithContext } from "../../lib/utility.js"; -import { useViewerContext } from "./ViewerProvider.js"; +import { ItemStore, useViewerContext } from "./ViewerProvider.js"; function topLevelName(path: SqValuePath): string { return { @@ -37,21 +37,22 @@ export function getChildrenValues(value: SqValue): SqValue[] { } } +export function traverseCalculatorEdge( + itemStore: ItemStore +): (SqValuePath) => SqValue | undefined { + return (calculatorSubPath: SqValuePath) => { + const calculatorState = itemStore.getCalculator(calculatorSubPath); + const result = calculatorState?.calculatorResult; + return result?.ok ? result.value : undefined; + }; +} + // This needs to be a hook because it relies on ItemStore to traverse calculator nodes in path. export function useGetSubvalueByPath() { const { itemStore } = useViewerContext(); return (topValue: SqValue, subValuePath: SqValuePath): SqValue | undefined => - topValue.getSubvalueByPath( - subValuePath, - (calculatorSubPath: SqValuePath) => { - // The previous path item is the one that is the parent of the calculator result. - // This is the one that we use in the ViewerContext to store information about the calculator. - const calculatorState = itemStore.getCalculator(calculatorSubPath); - const result = calculatorState?.calculatorResult; - return result?.ok ? result.value : undefined; - } - ); + topValue.getSubvalueByPath(subValuePath, traverseCalculatorEdge(itemStore)); } export function getValueComment(value: SqValueWithContext): string | undefined { diff --git a/packages/squiggle-lang/src/public/SqValuePath.ts b/packages/squiggle-lang/src/public/SqValuePath.ts index b28e34ae52..1c05494a8d 100644 --- a/packages/squiggle-lang/src/public/SqValuePath.ts +++ b/packages/squiggle-lang/src/public/SqValuePath.ts @@ -194,6 +194,17 @@ export class SqValuePath { return this.edges.at(-1); } + parent() { + if (this.edges.length === 0) { + return undefined; + } else { + return new SqValuePath({ + root: this.root, + edges: this.edges.slice(0, -1), + }); + } + } + extend(edge: SqValuePathEdge) { return new SqValuePath({ root: this.root, From cf604f6ffb2f222c611101099f38f1682979cc5b Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Mon, 22 Jan 2024 21:14:39 -0800 Subject: [PATCH 17/36] Cleaning up new SqViewNode file --- .../components/SquiggleViewer/SqViewNode.tsx | 76 +++++------ .../SquiggleViewer/ValueWithContextViewer.tsx | 4 +- .../SquiggleViewer/ViewerProvider.tsx | 122 +---------------- .../src/components/SquiggleViewer/utils.ts | 4 +- .../SquiggleViewer/viewerKeyboardEvents.ts | 127 ++++++++++++++++++ 5 files changed, 167 insertions(+), 166 deletions(-) create mode 100644 packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx index f24f7f3d60..a15c3cc54f 100644 --- a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -1,18 +1,6 @@ import { SqValue, SqValuePath } from "@quri/squiggle-lang"; -export function getChildrenValues(value: SqValue): SqValue[] { - switch (value.tag) { - case "Array": - return value.value.getValues(); - case "Dict": - return value.value.entries().map((a) => a[1]); - default: { - return []; - } - } -} - -type TraverseCalculatorEdge = (path: SqValuePath) => SqValue | undefined; +import { getChildrenValues, TraverseCalculatorEdge } from "./utils.js"; class SqValueNode { constructor( @@ -25,7 +13,7 @@ class SqValueNode { return this.path.uid(); } - isEqual = (other: SqValueNode) => { + isEqual = (other: SqValueNode): boolean => { return this.uid() === other.uid(); }; @@ -35,9 +23,6 @@ class SqValueNode { parent() { const parentPath = this.path.parent(); - if (!parentPath) { - return undefined; - } return parentPath ? new SqValueNode(this.root, parentPath, this.traverseCalculatorEdge) : undefined; @@ -63,12 +48,31 @@ class SqValueNode { } siblings() { - return this.parent()?.children() || []; + return this.parent()?.children() ?? []; + } + + prevSibling() { + const index = this.getParentIndex(); + const isRootOrError = index === -1; + const isFirstSibling = index === 0; + if (isRootOrError || isFirstSibling) { + return undefined; + } + return this.siblings()[index - 1]; + } + + nextSibling() { + const index = this.getParentIndex(); + const isRootOrError = index === -1; + const isLastSibling = index === this.siblings().length - 1; + if (isRootOrError || isLastSibling) { + return undefined; + } + return this.siblings()[index + 1]; } getParentIndex() { - const siblings = this.siblings(); - return siblings.findIndex(this.isEqual); + return this.siblings().findIndex(this.isEqual); } } @@ -97,49 +101,35 @@ export class SqViewNode { return new SqViewNode(node, this.params); } + // A helper function to make a node or undefined + makeU(node: SqValueNode | undefined) { + return node ? new SqViewNode(node, this.params) : undefined; + } + value(): SqValue | undefined { return this.node.sqValue(); } - isRoot() { return this.node.path.isRoot(); } - parent() { - const parent = this.node.parent(); - return parent ? this.make(parent) : undefined; + return this.makeU(this.node.parent()); } - children() { return this.node.children().map(this.make); } lastChild() { - return this.make(this.node.lastChild()!); + return this.makeU(this.node.lastChild()); } siblings() { return this.node.siblings().map(this.make); } - prevSibling() { - const index = this.node.getParentIndex(); - const isRootOrError = index === -1; - const isFirstSibling = index === 0; - if (isRootOrError || isFirstSibling) { - return undefined; - } - return this.siblings()[index - 1]; + return this.makeU(this.node.prevSibling()); } - nextSibling() { - const index = this.node.getParentIndex(); - const isRootOrError = index === -1; - const isLastSibling = index === this.node.siblings().length - 1; - if (isRootOrError || isLastSibling) { - return undefined; - } - return this.siblings()[index + 1]; + return this.makeU(this.node.nextSibling()); } - private isCollapsed() { return this.params.getIsCollapsed(this.node.path); } diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index a5247a1ac2..fb08f1131d 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -21,9 +21,11 @@ import { } from "./utils.js"; import { isArrowEvent, - useFocus, useFocusedItemEvent, useItemEvent, +} from "./viewerKeyboardEvents.js"; +import { + useFocus, useMergedSettings, useRegisterAsItemViewer, useScrollToEditorPath, diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 31127dc257..d258bef4b6 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -197,19 +197,6 @@ export class ItemStore { } } -type ArrowEvent = - | "ArrowDown" - | "ArrowUp" - | "ArrowLeft" - | "ArrowRight" - | "Enter"; - -export function isArrowEvent(str: string): str is ArrowEvent { - return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( - str - ); -} - type ViewerContextShape = { // Note that we don't store `localItemState` itself in the context (that would cause rerenders of the entire tree on each settings update). // Instead, we keep `localItemState` in local state and notify the global context via `setLocalItemState` to pass them down the component tree again if it got rebuilt from scratch. @@ -284,7 +271,7 @@ export function useSetLocalItemState() { }; } -function toggleCollapsed(itemStore: ItemStore, path: SqValuePath) { +export function toggleCollapsed(itemStore: ItemStore, path: SqValuePath) { itemStore.setState(path, (state) => ({ ...state, collapsed: !state?.collapsed, @@ -367,113 +354,6 @@ export function useScrollToEditorPath(path: SqValuePath) { }; } -const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { - const header = itemStore.handles[path.uid()]?.element.querySelector("header"); - if (header) { - header.focus(); - } -}; - -export function useItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, findNode } = useViewerContext(); - - return (event: string) => { - if (isArrowEvent(event)) { - const myNode = findNode(selected); - if (!myNode) { - return; - } - - switch (event) { - case "ArrowDown": { - const newPath = myNode.next()?.node.path; - newPath && focusHeader(newPath, itemStore); - break; - } - case "ArrowUp": { - const newPath = myNode.prev()?.node.path; - newPath && focusHeader(newPath, itemStore); - break; - } - case "ArrowLeft": { - const newItem = myNode.parent(); - newItem && - !newItem.isRoot() && - focusHeader(newItem.node.path, itemStore); - break; - } - case "ArrowRight": { - toggleCollapsed(itemStore, selected); - break; - } - case "Enter": { - setFocused(selected); - break; - } - } - } - }; -} - -export function useFocusedItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, findNode } = useViewerContext(); - - function resetToRoot() { - setFocused(undefined); - setTimeout(() => { - focusHeader(selected, itemStore); - }, 1); - } - - return (event: string) => { - const myNode = findNode(selected); - if (!myNode) { - return; - } - - if (isArrowEvent(event)) { - switch (event) { - case "ArrowDown": { - const newItem = myNode.children()[0]; - if (newItem) { - focusHeader(newItem.node.path, itemStore); - } - break; - } - case "ArrowUp": { - const newItem = myNode.parent(); - if (newItem) { - if (newItem.isRoot()) { - resetToRoot(); - } else { - setFocused(newItem.node.path); - } - } - break; - } - case "ArrowLeft": { - const newPath = myNode.prevSibling()?.node.path; - if (newPath) { - setFocused(newPath); - } - break; - } - case "ArrowRight": { - const newPath = myNode.nextSibling()?.node.path; - if (newPath) { - setFocused(newPath); - } - break; - } - case "Enter": { - resetToRoot(); - break; - } - } - } - }; -} - export function useIsFocused(path: SqValuePath) { const { focused } = useViewerContext(); return focused?.isEqual(path); diff --git a/packages/components/src/components/SquiggleViewer/utils.ts b/packages/components/src/components/SquiggleViewer/utils.ts index 9a57a2b411..d07c2bcd79 100644 --- a/packages/components/src/components/SquiggleViewer/utils.ts +++ b/packages/components/src/components/SquiggleViewer/utils.ts @@ -37,9 +37,11 @@ export function getChildrenValues(value: SqValue): SqValue[] { } } +export type TraverseCalculatorEdge = (path: SqValuePath) => SqValue | undefined; + export function traverseCalculatorEdge( itemStore: ItemStore -): (SqValuePath) => SqValue | undefined { +): TraverseCalculatorEdge { return (calculatorSubPath: SqValuePath) => { const calculatorState = itemStore.getCalculator(calculatorSubPath); const result = calculatorState?.calculatorResult; diff --git a/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts b/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts new file mode 100644 index 0000000000..d7fddeef9c --- /dev/null +++ b/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts @@ -0,0 +1,127 @@ +import { SqValuePath } from "@quri/squiggle-lang"; + +import { + ItemStore, + toggleCollapsed, + useViewerContext, +} from "./ViewerProvider.js"; + +type ArrowEvent = + | "ArrowDown" + | "ArrowUp" + | "ArrowLeft" + | "ArrowRight" + | "Enter"; + +export function isArrowEvent(str: string): str is ArrowEvent { + return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( + str + ); +} + +const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { + const header = itemStore.handles[path.uid()]?.element.querySelector("header"); + if (header) { + header.focus(); + } +}; + +export function useItemEvent(selected: SqValuePath) { + const { setFocused, itemStore, findNode } = useViewerContext(); + + return (event: string) => { + if (isArrowEvent(event)) { + const myNode = findNode(selected); + if (!myNode) { + return; + } + + switch (event) { + case "ArrowDown": { + const newPath = myNode.next()?.node.path; + newPath && focusHeader(newPath, itemStore); + break; + } + case "ArrowUp": { + const newPath = myNode.prev()?.node.path; + newPath && focusHeader(newPath, itemStore); + break; + } + case "ArrowLeft": { + const newItem = myNode.parent(); + newItem && + !newItem.isRoot() && + focusHeader(newItem.node.path, itemStore); + break; + } + case "ArrowRight": { + toggleCollapsed(itemStore, selected); + break; + } + case "Enter": { + setFocused(selected); + break; + } + } + } + }; +} + +export function useFocusedItemEvent(selected: SqValuePath) { + const { setFocused, itemStore, findNode } = useViewerContext(); + + function resetToRoot() { + setFocused(undefined); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + } + + return (event: string) => { + const myNode = findNode(selected); + if (!myNode) { + return; + } + + if (isArrowEvent(event)) { + switch (event) { + case "ArrowDown": { + const newItem = myNode.children()[0]; + if (newItem) { + focusHeader(newItem.node.path, itemStore); + } + break; + } + case "ArrowUp": { + const newItem = myNode.parent(); + if (newItem) { + if (newItem.isRoot()) { + resetToRoot(); + } else { + setFocused(newItem.node.path); + } + } + break; + } + case "ArrowLeft": { + const newPath = myNode.prevSibling()?.node.path; + if (newPath) { + setFocused(newPath); + } + break; + } + case "ArrowRight": { + const newPath = myNode.nextSibling()?.node.path; + if (newPath) { + setFocused(newPath); + } + break; + } + case "Enter": { + resetToRoot(); + break; + } + } + } + }; +} From 00e668dd98b9b54e59ca5149cdffd930504c7d15 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 10:50:05 -0800 Subject: [PATCH 18/36] Refactored keyboard nav code into new keyboardNav directory --- .../CodeEditor/useTooltipsExtension.tsx | 2 +- .../SquiggleOutputViewer/ViewerBody.tsx | 38 ++++-- .../components/SquiggleOutputViewer/index.tsx | 4 +- .../components/SquiggleViewer/SqViewNode.tsx | 33 +++-- .../SquiggleViewer/ValueWithContextViewer.tsx | 21 ++- .../SquiggleViewer/ViewerProvider.tsx | 46 ++----- .../src/components/SquiggleViewer/index.tsx | 2 +- .../keyboardNav/focusedSqValue.ts | 55 ++++++++ .../keyboardNav/unfocusedSqValue.ts | 37 +++++ .../SquiggleViewer/keyboardNav/utils.ts | 37 +++++ .../SquiggleViewer/viewerKeyboardEvents.ts | 127 ------------------ 11 files changed, 193 insertions(+), 209 deletions(-) create mode 100644 packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts create mode 100644 packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts create mode 100644 packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts delete mode 100644 packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts diff --git a/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx b/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx index 0ce5e7e945..a7a142711f 100644 --- a/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx +++ b/packages/components/src/components/CodeEditor/useTooltipsExtension.tsx @@ -48,7 +48,7 @@ const ValueTooltip: FC<{ value: SqValue; view: EditorView }> = ({ diff --git a/packages/components/src/components/SquiggleOutputViewer/ViewerBody.tsx b/packages/components/src/components/SquiggleOutputViewer/ViewerBody.tsx index ecd2dc482f..969f5f3ede 100644 --- a/packages/components/src/components/SquiggleOutputViewer/ViewerBody.tsx +++ b/packages/components/src/components/SquiggleOutputViewer/ViewerBody.tsx @@ -14,6 +14,28 @@ type Props = { isRunning: boolean; }; +export function modeToValue( + mode: ViewerMode, + output: SqOutputResult +): SqValue | undefined { + if (!output.ok) { + return; + } + const sqOutput = output.value; + switch (mode) { + case "Result": + return sqOutput.result; + case "Variables": + return sqOutput.bindings.asValue(); + case "Imports": + return sqOutput.imports.asValue(); + case "Exports": + return sqOutput.exports.asValue(); + case "AST": + return; + } +} + export const ViewerBody: FC = ({ output, mode, isRunning }) => { if (!output.ok) { return ; @@ -28,20 +50,8 @@ export const ViewerBody: FC = ({ output, mode, isRunning }) => { ); } - let usedValue: SqValue | undefined; - switch (mode) { - case "Result": - usedValue = output.value.result; - break; - case "Variables": - usedValue = sqOutput.bindings.asValue(); - break; - case "Imports": - usedValue = sqOutput.imports.asValue(); - break; - case "Exports": - usedValue = sqOutput.exports.asValue(); - } + + const usedValue = modeToValue(mode, output); if (!usedValue) { return null; diff --git a/packages/components/src/components/SquiggleOutputViewer/index.tsx b/packages/components/src/components/SquiggleOutputViewer/index.tsx index b0c948e5ca..e5be1daf03 100644 --- a/packages/components/src/components/SquiggleOutputViewer/index.tsx +++ b/packages/components/src/components/SquiggleOutputViewer/index.tsx @@ -10,7 +10,7 @@ import { } from "../SquiggleViewer/ViewerProvider.js"; import { Layout } from "./Layout.js"; import { RenderingIndicator } from "./RenderingIndicator.js"; -import { ViewerBody } from "./ViewerBody.js"; +import { modeToValue, ViewerBody } from "./ViewerBody.js"; import { ViewerMenu } from "./ViewerMenu.js"; type Props = { @@ -51,7 +51,7 @@ export const SquiggleOutputViewer = forwardRef( partialPlaygroundSettings={settings} editor={editor} ref={viewerRef} - value={(output.ok && output.value.bindings.asValue()) || undefined} + rootValue={modeToValue(mode, output) || undefined} > } diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx index a15c3cc54f..c792688d99 100644 --- a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -2,6 +2,7 @@ import { SqValue, SqValuePath } from "@quri/squiggle-lang"; import { getChildrenValues, TraverseCalculatorEdge } from "./utils.js"; +//We might want to bring this into the SquiggleLang library. The ``traverseCalculatorEdge`` part is awkward though. class SqValueNode { constructor( public root: SqValue, @@ -79,7 +80,8 @@ class SqValueNode { type GetIsCollapsed = (path: SqValuePath) => boolean; type Params = { getIsCollapsed: GetIsCollapsed }; -export class SqViewNode { +//This is split from SqValueNode because it handles more specialized logic for viewing open/closed nodes in the Viewer. It works for lists of nodes - we'll need new logic for tabular data. +export class SqListViewNode { constructor( public node: SqValueNode, public params: Params @@ -94,16 +96,16 @@ export class SqViewNode { getIsCollapsed: GetIsCollapsed ) { const node = new SqValueNode(root, path, traverseCalculatorEdge); - return new SqViewNode(node, { getIsCollapsed }); + return new SqListViewNode(node, { getIsCollapsed }); } make(node: SqValueNode) { - return new SqViewNode(node, this.params); + return new SqListViewNode(node, this.params); } // A helper function to make a node or undefined makeU(node: SqValueNode | undefined) { - return node ? new SqViewNode(node, this.params) : undefined; + return node ? new SqListViewNode(node, this.params) : undefined; } value(): SqValue | undefined { @@ -134,12 +136,12 @@ export class SqViewNode { return this.params.getIsCollapsed(this.node.path); } - private childrenAreVisible() { - return !this.isCollapsed(); + private hasVisibleChildren() { + return !this.isCollapsed() && this.children().length > 0; } - private lastVisibleSubChild(): SqViewNode | undefined { - if (this.children.length > 0 && this.childrenAreVisible()) { + private lastVisibleSubChild(): SqListViewNode | undefined { + if (this.hasVisibleChildren()) { const lastChild = this.lastChild(); return lastChild?.lastVisibleSubChild() || lastChild; } else { @@ -147,21 +149,18 @@ export class SqViewNode { } } - private nextAvailableSibling(): SqViewNode | undefined { - return this.nextSibling() || this.parent(); + private nextAvailableSibling(): SqListViewNode | undefined { + return this.nextSibling() || this.parent()?.nextAvailableSibling(); } - next(): SqViewNode | undefined { - return this.children().length > 0 && !this.isCollapsed() + next(): SqListViewNode | undefined { + return this.hasVisibleChildren() ? this.children()[0] : this.nextAvailableSibling(); } - prev(): SqViewNode | undefined { + prev(): SqListViewNode | undefined { const prevSibling = this.prevSibling(); - if (!prevSibling) { - return this.parent(); - } - return prevSibling.lastVisibleSubChild(); + return prevSibling ? prevSibling.lastVisibleSubChild() : this.parent(); } } diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index fb08f1131d..3ba2efcb62 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -11,6 +11,8 @@ import { MarkdownViewer } from "../../lib/MarkdownViewer.js"; import { SqValueWithContext } from "../../lib/utility.js"; import { ErrorBoundary } from "../ErrorBoundary.js"; import { CollapsedIcon, ExpandedIcon } from "./icons.js"; +import { useFocusedSqValueKeyEvent } from "./keyboardNav/focusedSqValue.js"; +import { useUnfocusedSqValueKeyEvent } from "./keyboardNav/unfocusedSqValue.js"; import { SquiggleValueChart } from "./SquiggleValueChart.js"; import { SquiggleValueMenu } from "./SquiggleValueMenu.js"; import { SquiggleValuePreview } from "./SquiggleValuePreview.js"; @@ -19,11 +21,6 @@ import { hasExtraContentToShow, pathToShortName, } from "./utils.js"; -import { - isArrowEvent, - useFocusedItemEvent, - useItemEvent, -} from "./viewerKeyboardEvents.js"; import { useFocus, useMergedSettings, @@ -124,8 +121,9 @@ export const ValueWithContextViewer: FC = ({ const toggleCollapsed_ = useToggleCollapsed(); const focus = useFocus(); - const itemEvent = useItemEvent(path); - const focusedItemEvent = useFocusedItemEvent(path); + const focusedKeyEvent = useFocusedSqValueKeyEvent(path); + const unfocusedKeyEvent = useUnfocusedSqValueKeyEvent(path); + const viewerType = useViewerType(); const scrollEditorToPath = useScrollToEditorPath(path); @@ -274,12 +272,9 @@ export const ValueWithContextViewer: FC = ({ scrollEditorToPath(); }} onKeyDown={(event) => { - if (isArrowEvent(event.key)) { - event.preventDefault(); - size === "large" - ? focusedItemEvent(event.key as string) - : itemEvent(event.key as string); - } + size === "large" + ? focusedKeyEvent(event) + : unfocusedKeyEvent(event); }} >
diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index d258bef4b6..c3aed7c27d 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -23,7 +23,7 @@ import { PartialPlaygroundSettings, PlaygroundSettings, } from "../PlaygroundSettings.js"; -import { SqViewNode } from "./SqViewNode.js"; +import { SqListViewNode } from "./SqViewNode.js"; import { getChildrenValues, shouldBeginCollapsed, @@ -40,7 +40,7 @@ function findNode( if (!root || !path) { return; } - return SqViewNode.make( + return SqListViewNode.make( root, path, traverseCalculatorEdge(itemStore), @@ -73,22 +73,6 @@ const defaultLocalItemState: LocalItemState = { type ValuePathUID = string; -function isElementInView(element: HTMLElement) { - const elementRect = element.getBoundingClientRect(); - const container = document.querySelector( - '[data-testid="dynamic-viewer-result"]' - ); - if (!container) { - return false; - } - - const containerRect = container.getBoundingClientRect(); - - return ( - elementRect.top >= containerRect.top && - elementRect.top + 20 <= containerRect.bottom - ); -} /** * `ItemStore` is used for caching and for passing settings down the tree. * It allows us to avoid React tree rerenders on settings changes; instead, we can rerender individual item viewers on demand. @@ -188,13 +172,9 @@ export class ItemStore { scrollViewerToPath(path: SqValuePath) { // setFocused(path); this.handles[path.uid()]?.element.scrollIntoView({ - behavior: "instant", + behavior: "smooth", }); } - - isInView(path: SqValuePath) { - return isElementInView(this.handles[path.uid()]?.element); - } } type ViewerContextShape = { @@ -210,7 +190,7 @@ type ViewerContextShape = { initialized: boolean; handle: SquiggleViewerHandle; rootValue?: SqValueWithContext; - findNode: (path: SqValuePath) => SqViewNode | undefined; + findNode: (path: SqValuePath) => SqListViewNode | undefined; }; export const ViewerContext = createContext({ @@ -255,9 +235,7 @@ export function useRegisterAsItemViewer(path: SqValuePath) { itemStore.registerItemHandle(path, { element, forceUpdate }); - return () => { - itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often - }; + return () => itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often }); return ref; @@ -380,7 +358,7 @@ type Props = PropsWithChildren<{ partialPlaygroundSettings: PartialPlaygroundSettings; editor?: CodeEditorHandle; viewerType?: ViewerType; - value: SqValue | undefined; + rootValue: SqValue | undefined; }>; export const InnerViewerProvider = forwardRef( @@ -389,7 +367,7 @@ export const InnerViewerProvider = forwardRef( partialPlaygroundSettings: unstablePlaygroundSettings, editor, viewerType = "normal", - value, + rootValue, children, }, ref @@ -419,16 +397,16 @@ export const InnerViewerProvider = forwardRef( useImperativeHandle(ref, () => handle); - const _value = value - ? valueHasContext(value) - ? value + const _rootValue = rootValue + ? valueHasContext(rootValue) + ? rootValue : undefined : undefined; return ( ( viewerType, handle, initialized: true, - findNode: (path) => findNode(_value, path, itemStore), + findNode: (path) => findNode(_rootValue, path, itemStore), }} > {children} diff --git a/packages/components/src/components/SquiggleViewer/index.tsx b/packages/components/src/components/SquiggleViewer/index.tsx index f37e9c0473..bc6e092419 100644 --- a/packages/components/src/components/SquiggleViewer/index.tsx +++ b/packages/components/src/components/SquiggleViewer/index.tsx @@ -113,7 +113,7 @@ const component = forwardRef( partialPlaygroundSettings={partialPlaygroundSettings} editor={editor} ref={ref} - value={value} + rootValue={value} > diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts new file mode 100644 index 0000000000..ecfb361187 --- /dev/null +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts @@ -0,0 +1,55 @@ +import { SqValuePath } from "@quri/squiggle-lang"; + +import { useViewerContext } from "../ViewerProvider.js"; +import { focusHeader, keyboardEventHandler } from "./utils.js"; + +const validKeys = [ + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", + "Enter", +] as const; + +export function useFocusedSqValueKeyEvent(selected: SqValuePath) { + const { setFocused, itemStore, findNode } = useViewerContext(); + + function resetToRoot() { + setFocused(undefined); + setTimeout(() => { + focusHeader(selected, itemStore); + }, 1); + } + + return keyboardEventHandler(validKeys, { + ArrowDown: () => { + const newItem = findNode(selected)?.children()[0]; + if (newItem) { + focusHeader(newItem.node.path, itemStore); + } + }, + ArrowUp: () => { + const newItem = findNode(selected)?.parent(); + if (newItem) { + if (newItem.isRoot()) { + resetToRoot(); + } else { + setFocused(newItem.node.path); + } + } + }, + ArrowLeft: () => { + const newPath = findNode(selected)?.prevSibling()?.node.path; + if (newPath) { + setFocused(newPath); + } + }, + ArrowRight: () => { + const newPath = findNode(selected)?.nextSibling()?.node.path; + if (newPath) { + setFocused(newPath); + } + }, + Enter: resetToRoot, + }); +} diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts new file mode 100644 index 0000000000..c73c42c5be --- /dev/null +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts @@ -0,0 +1,37 @@ +import { SqValuePath } from "@quri/squiggle-lang"; + +import { toggleCollapsed, useViewerContext } from "../ViewerProvider.js"; +import { focusHeader, keyboardEventHandler } from "./utils.js"; + +const validKeys = [ + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", + "Enter", +] as const; + +export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { + const { setFocused, itemStore, findNode } = useViewerContext(); + + return keyboardEventHandler(validKeys, { + ArrowDown: () => { + const newPath = findNode(selected)?.next()?.node.path; + newPath && focusHeader(newPath, itemStore); + }, + ArrowUp: () => { + const newPath = findNode(selected)?.prev()?.node.path; + newPath && focusHeader(newPath, itemStore); + }, + ArrowLeft: () => { + const newItem = findNode(selected)?.parent(); + newItem && !newItem.isRoot() && focusHeader(newItem.node.path, itemStore); + }, + ArrowRight: () => { + toggleCollapsed(itemStore, selected); + }, + Enter: () => { + setFocused(selected); + }, + }); +} diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts new file mode 100644 index 0000000000..b233dd0081 --- /dev/null +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -0,0 +1,37 @@ +import { SqValuePath } from "@quri/squiggle-lang"; + +import { ItemStore } from "../ViewerProvider.js"; + +export const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { + const header = itemStore.handles[path.uid()]?.element.querySelector("header"); + if (header) { + header.focus(); + } +}; + +export function isValidKey( + key: string, + validKeys: T +): key is T[number] { + return validKeys.includes(key as T[number]); +} + +type KeyHandler = (eventKey: string) => void; + +// Returns boolean to indicate if the key was handled. The caller might want to do something else if it wasn't. +export function keyboardEventHandler( + validKeys: T, + handlers: Partial> +) { + return (event: React.KeyboardEvent) => + ((eventKey: string): boolean => { + if (isValidKey(eventKey, validKeys)) { + const handler = handlers[eventKey]; + if (handler) { + handler(eventKey); + } + return true; + } + return false; + })(event.key); +} diff --git a/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts b/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts deleted file mode 100644 index d7fddeef9c..0000000000 --- a/packages/components/src/components/SquiggleViewer/viewerKeyboardEvents.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { SqValuePath } from "@quri/squiggle-lang"; - -import { - ItemStore, - toggleCollapsed, - useViewerContext, -} from "./ViewerProvider.js"; - -type ArrowEvent = - | "ArrowDown" - | "ArrowUp" - | "ArrowLeft" - | "ArrowRight" - | "Enter"; - -export function isArrowEvent(str: string): str is ArrowEvent { - return ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter"].includes( - str - ); -} - -const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { - const header = itemStore.handles[path.uid()]?.element.querySelector("header"); - if (header) { - header.focus(); - } -}; - -export function useItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, findNode } = useViewerContext(); - - return (event: string) => { - if (isArrowEvent(event)) { - const myNode = findNode(selected); - if (!myNode) { - return; - } - - switch (event) { - case "ArrowDown": { - const newPath = myNode.next()?.node.path; - newPath && focusHeader(newPath, itemStore); - break; - } - case "ArrowUp": { - const newPath = myNode.prev()?.node.path; - newPath && focusHeader(newPath, itemStore); - break; - } - case "ArrowLeft": { - const newItem = myNode.parent(); - newItem && - !newItem.isRoot() && - focusHeader(newItem.node.path, itemStore); - break; - } - case "ArrowRight": { - toggleCollapsed(itemStore, selected); - break; - } - case "Enter": { - setFocused(selected); - break; - } - } - } - }; -} - -export function useFocusedItemEvent(selected: SqValuePath) { - const { setFocused, itemStore, findNode } = useViewerContext(); - - function resetToRoot() { - setFocused(undefined); - setTimeout(() => { - focusHeader(selected, itemStore); - }, 1); - } - - return (event: string) => { - const myNode = findNode(selected); - if (!myNode) { - return; - } - - if (isArrowEvent(event)) { - switch (event) { - case "ArrowDown": { - const newItem = myNode.children()[0]; - if (newItem) { - focusHeader(newItem.node.path, itemStore); - } - break; - } - case "ArrowUp": { - const newItem = myNode.parent(); - if (newItem) { - if (newItem.isRoot()) { - resetToRoot(); - } else { - setFocused(newItem.node.path); - } - } - break; - } - case "ArrowLeft": { - const newPath = myNode.prevSibling()?.node.path; - if (newPath) { - setFocused(newPath); - } - break; - } - case "ArrowRight": { - const newPath = myNode.nextSibling()?.node.path; - if (newPath) { - setFocused(newPath); - } - break; - } - case "Enter": { - resetToRoot(); - break; - } - } - } - }; -} From 295c0450302c2e72b42d174fd252e79d6d0abe89 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 10:59:38 -0800 Subject: [PATCH 19/36] Removed find-in-editor in dropdown --- .../src/components/CodeEditor/index.tsx | 6 ++--- .../src/components/SquiggleErrorAlert.tsx | 2 +- .../SquiggleViewer/SquiggleValueMenu.tsx | 25 ------------------- .../SquiggleViewer/ViewerProvider.tsx | 2 +- .../keyboardNav/unfocusedSqValue.ts | 11 +++++++- .../SquiggleViewer/keyboardNav/utils.ts | 1 + 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/components/src/components/CodeEditor/index.tsx b/packages/components/src/components/CodeEditor/index.tsx index 01e892b194..e0d30181e0 100644 --- a/packages/components/src/components/CodeEditor/index.tsx +++ b/packages/components/src/components/CodeEditor/index.tsx @@ -26,20 +26,20 @@ export type CodeEditorProps = { export type CodeEditorHandle = { format(): void; - scrollTo(position: number): void; + scrollTo(position: number, focus: boolean): void; }; export const CodeEditor = forwardRef( function CodeEditor(props, ref) { const { view, ref: editorRef } = useSquiggleEditorView(props); - const scrollTo = (position: number) => { + const scrollTo = (position: number, focus) => { if (!view) return; view.dispatch({ selection: { anchor: position }, scrollIntoView: true, }); - // view.focus(); + focus && view.focus(); }; useImperativeHandle(ref, () => ({ diff --git a/packages/components/src/components/SquiggleErrorAlert.tsx b/packages/components/src/components/SquiggleErrorAlert.tsx index 256ddf1d53..293cdcb7e0 100644 --- a/packages/components/src/components/SquiggleErrorAlert.tsx +++ b/packages/components/src/components/SquiggleErrorAlert.tsx @@ -21,7 +21,7 @@ const LocationLine: FC<{ const { editor } = useViewerContext(); const findInEditor = () => { - editor?.scrollTo(location.start.offset); + editor?.scrollTo(location.start.offset, true); }; return ( diff --git a/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx b/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx index fe496076f7..8e06f1281f 100644 --- a/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx +++ b/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx @@ -2,7 +2,6 @@ import { clsx } from "clsx"; import { FC } from "react"; import { - CodeBracketIcon, Cog8ToothIcon, CommandLineIcon, Dropdown, @@ -27,29 +26,6 @@ import { useViewerContext, } from "./ViewerProvider.js"; -const FindInEditorItem: FC<{ value: SqValueWithContext }> = ({ value }) => { - const { editor } = useViewerContext(); - const closeDropdown = useCloseDropdown(); - - if (!editor || value.context.path.isRoot()) { - return null; - } - - const findInEditor = () => { - const location = value.context.findLocation(); - editor?.scrollTo(location.start.offset); - closeDropdown(); - }; - - return ( - - ); -}; - const FocusItem: FC<{ value: SqValueWithContext }> = ({ value }) => { const { path } = value.context; const isFocused = useIsFocused(path); @@ -153,7 +129,6 @@ export const SquiggleValueMenu: FC<{ {widgetHeading && ( {widgetHeading} )} - { @@ -33,5 +34,13 @@ export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { Enter: () => { setFocused(selected); }, + e: () => { + const value = findNode(selected)?.value(); + const location = value?.context?.findLocation(); + + if (location) { + editor?.scrollTo(location.start.offset, true); + } + }, }); } diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index b233dd0081..e81b3c3006 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -27,6 +27,7 @@ export function keyboardEventHandler( ((eventKey: string): boolean => { if (isValidKey(eventKey, validKeys)) { const handler = handlers[eventKey]; + event.preventDefault(); if (handler) { handler(eventKey); } From 62d47d58ff166cfea4dda176e7e0bd44a37a0ea7 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 11:59:12 -0800 Subject: [PATCH 20/36] Minor renaming --- .../src/components/SquiggleViewer/ValueViewer.tsx | 8 +------- .../src/components/SquiggleViewer/ViewerProvider.tsx | 1 - .../SquiggleViewer/keyboardNav/focusedSqValue.ts | 8 +++++--- .../SquiggleViewer/keyboardNav/unfocusedSqValue.ts | 11 +++++++---- .../components/SquiggleViewer/keyboardNav/utils.ts | 2 +- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx index 71efa15648..065f534b70 100644 --- a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx @@ -16,11 +16,5 @@ export const ValueViewer: React.FC = ({ value, ...rest }) => { return ; } - return ( - - ); + return ; }; diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index e2366c63bc..792ea2631e 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -170,7 +170,6 @@ export class ItemStore { } scrollViewerToPath(path: SqValuePath) { - // setFocused(path); this.handles[path.uid()]?.element.scrollIntoView({ behavior: "smooth", }); diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts index ecfb361187..b3b8bfc38b 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts @@ -1,7 +1,7 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { useViewerContext } from "../ViewerProvider.js"; -import { focusHeader, keyboardEventHandler } from "./utils.js"; +import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; const validKeys = [ "ArrowDown", @@ -16,8 +16,10 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { function resetToRoot() { setFocused(undefined); + + // This timeout is a hack to make sure the header is focused after the reset setTimeout(() => { - focusHeader(selected, itemStore); + focusSqValueHeader(selected, itemStore); }, 1); } @@ -25,7 +27,7 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { ArrowDown: () => { const newItem = findNode(selected)?.children()[0]; if (newItem) { - focusHeader(newItem.node.path, itemStore); + focusSqValueHeader(newItem.node.path, itemStore); } }, ArrowUp: () => { diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts index 024e6b2d56..55d4295ea8 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts @@ -1,7 +1,7 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { toggleCollapsed, useViewerContext } from "../ViewerProvider.js"; -import { focusHeader, keyboardEventHandler } from "./utils.js"; +import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; const validKeys = [ "ArrowDown", @@ -18,15 +18,17 @@ export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { return keyboardEventHandler(validKeys, { ArrowDown: () => { const newPath = findNode(selected)?.next()?.node.path; - newPath && focusHeader(newPath, itemStore); + newPath && focusSqValueHeader(newPath, itemStore); }, ArrowUp: () => { const newPath = findNode(selected)?.prev()?.node.path; - newPath && focusHeader(newPath, itemStore); + newPath && focusSqValueHeader(newPath, itemStore); }, ArrowLeft: () => { const newItem = findNode(selected)?.parent(); - newItem && !newItem.isRoot() && focusHeader(newItem.node.path, itemStore); + newItem && + !newItem.isRoot() && + focusSqValueHeader(newItem.node.path, itemStore); }, ArrowRight: () => { toggleCollapsed(itemStore, selected); @@ -34,6 +36,7 @@ export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { Enter: () => { setFocused(selected); }, + //e for "edit." Focuses the line and focuses it. e: () => { const value = findNode(selected)?.value(); const location = value?.context?.findLocation(); diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index e81b3c3006..0985073ae8 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -2,7 +2,7 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { ItemStore } from "../ViewerProvider.js"; -export const focusHeader = (path: SqValuePath, itemStore: ItemStore) => { +export const focusSqValueHeader = (path: SqValuePath, itemStore: ItemStore) => { const header = itemStore.handles[path.uid()]?.element.querySelector("header"); if (header) { header.focus(); From 8f91960d8e9dff7f50259f73e7e103e04c71065b Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 12:30:07 -0800 Subject: [PATCH 21/36] Minor fixes --- .../SquiggleViewer/ValueWithContextViewer.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 3ba2efcb62..8a79c5eaa9 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -243,18 +243,15 @@ export const ValueWithContextViewer: FC = ({ }; const extraHeaderClasses = () => { - return ( - "hover:bg-stone-100 rounded-sm focus-visible:outline-none " + - (header === "large" - ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" - : "focus:bg-indigo-100") - ); + return focused + ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" + : "focus:bg-indigo-100"; }; useEffect(() => { const header = ref.current?.querySelector("header"); - if (isFocused && !isRoot) { - header?.focus(); + if (isFocused && !isRoot && header) { + header.focus(); } }, []); @@ -265,16 +262,14 @@ export const ValueWithContextViewer: FC = ({
{ scrollEditorToPath(); }} onKeyDown={(event) => { - size === "large" - ? focusedKeyEvent(event) - : unfocusedKeyEvent(event); + isFocused ? focusedKeyEvent(event) : unfocusedKeyEvent(event); }} >
From e5f5bfbc84d3844c8e320ed7b9959954ca11b4c4 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 12:43:33 -0800 Subject: [PATCH 22/36] More small changes --- .../SquiggleViewer/ValueWithContextViewer.tsx | 27 +++++++++---------- .../SquiggleViewer/keyboardNav/utils.ts | 6 ++--- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 8a79c5eaa9..e639f4c884 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -111,6 +111,10 @@ const ValueViewerBody: FC = ({ value, size = "normal" }) => { ); }; +export function focusOnHeader(element: HTMLDivElement) { + element.querySelector("header")?.focus(); +} + export const ValueWithContextViewer: FC = ({ value, parentValue, @@ -151,6 +155,12 @@ export const ValueWithContextViewer: FC = ({ // In that case, the output would look broken (empty). const isOpen = !collapsible || !itemState.collapsed; + useEffect(() => { + if (isFocused && !isRoot && ref.current) { + focusOnHeader(ref.current); + } + }, []); + const _focus = () => { if (!enableFocus) { return; @@ -242,19 +252,6 @@ export const ValueWithContextViewer: FC = ({ } }; - const extraHeaderClasses = () => { - return focused - ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" - : "focus:bg-indigo-100"; - }; - - useEffect(() => { - const header = ref.current?.querySelector("header"); - if (isFocused && !isRoot && header) { - header.focus(); - } - }, []); - return (
@@ -263,7 +260,9 @@ export const ValueWithContextViewer: FC = ({ tabIndex={viewerType === "tooltip" ? undefined : 0} className={clsx( "flex justify-between group pr-0.5 hover:bg-stone-100 rounded-sm focus-visible:outline-none", - extraHeaderClasses() + focused + ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" + : "focus:bg-indigo-1V00" )} onFocus={(_) => { scrollEditorToPath(); diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index 0985073ae8..a395654de3 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -1,12 +1,10 @@ import { SqValuePath } from "@quri/squiggle-lang"; +import { focusOnHeader } from "../ValueWithContextViewer.js"; import { ItemStore } from "../ViewerProvider.js"; export const focusSqValueHeader = (path: SqValuePath, itemStore: ItemStore) => { - const header = itemStore.handles[path.uid()]?.element.querySelector("header"); - if (header) { - header.focus(); - } + focusOnHeader(itemStore.handles[path.uid()].element); }; export function isValidKey( From e8751bf25ff1f60c8a47ee1777a6a43daced6e84 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Tue, 23 Jan 2024 13:15:23 -0800 Subject: [PATCH 23/36] Tiny fix --- .../src/components/SquiggleViewer/ValueWithContextViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index e639f4c884..0752d408dc 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -262,7 +262,7 @@ export const ValueWithContextViewer: FC = ({ "flex justify-between group pr-0.5 hover:bg-stone-100 rounded-sm focus-visible:outline-none", focused ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" - : "focus:bg-indigo-1V00" + : "focus:bg-indigo-100" )} onFocus={(_) => { scrollEditorToPath(); From 5727765fdf93fea0d01de06275a902b0458b58fb Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 13:15:34 -0800 Subject: [PATCH 24/36] Minor focused fix --- .../components/SquiggleViewer/ValueWithContextViewer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 0752d408dc..091896caa7 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -131,8 +131,8 @@ export const ValueWithContextViewer: FC = ({ const viewerType = useViewerType(); const scrollEditorToPath = useScrollToEditorPath(path); - const { itemStore, focused } = useViewerContext(); - const isFocused = focused?.isEqual(path); + const { itemStore, focused: _focused } = useViewerContext(); + const isFocused = _focused?.isEqual(path); const itemState = itemStore.getStateOrInitialize(value); const isRoot = path.isRoot(); @@ -260,7 +260,7 @@ export const ValueWithContextViewer: FC = ({ tabIndex={viewerType === "tooltip" ? undefined : 0} className={clsx( "flex justify-between group pr-0.5 hover:bg-stone-100 rounded-sm focus-visible:outline-none", - focused + isFocused ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" : "focus:bg-indigo-100" )} From 897c476e42bc08a2a374ee3a4058d719afd23dbb Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 17:33:57 -0800 Subject: [PATCH 25/36] Made/use/store ValueWithContextViewerHandle refactor --- .../SquiggleViewer/ValueWithContextViewer.tsx | 66 ++++++++++++------- .../SquiggleViewer/ViewerProvider.tsx | 33 +++------- .../SquiggleViewer/keyboardNav/utils.ts | 3 +- 3 files changed, 53 insertions(+), 49 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 091896caa7..5d473f7d12 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -2,11 +2,12 @@ import "../../widgets/index.js"; import { clsx } from "clsx"; -import { FC, PropsWithChildren, useEffect, useMemo } from "react"; +import { FC, PropsWithChildren, useEffect, useMemo, useRef } from "react"; import { SqValue } from "@quri/squiggle-lang"; import { CommentIcon, TextTooltip } from "@quri/ui"; +import { useForceUpdate } from "../../lib/hooks/useForceUpdate.js"; import { MarkdownViewer } from "../../lib/MarkdownViewer.js"; import { SqValueWithContext } from "../../lib/utility.js"; import { ErrorBoundary } from "../ErrorBoundary.js"; @@ -111,9 +112,11 @@ const ValueViewerBody: FC = ({ value, size = "normal" }) => { ); }; -export function focusOnHeader(element: HTMLDivElement) { - element.querySelector("header")?.focus(); -} +export type ValueWithContextViewerHandle = { + forceUpdate: () => void; + scrollIntoView: () => void; + focusOnHeader: () => void; +}; export const ValueWithContextViewer: FC = ({ value, @@ -123,8 +126,26 @@ export const ValueWithContextViewer: FC = ({ const { tag } = value; const { path } = value.context; + const containerRef = useRef(null); + const headerRef = useRef(null); + + const handle = { + scrollIntoView: () => { + containerRef?.current?.scrollIntoView({ + behavior: "smooth", + }); + }, + forceUpdate: useForceUpdate(), + focusOnHeader: () => { + headerRef.current?.focus(); + }, + }; + + useRegisterAsItemViewer(path, handle); + const toggleCollapsed_ = useToggleCollapsed(); - const focus = useFocus(); + const _focus = useFocus(); + const focus = () => enableFocus && _focus(path); const focusedKeyEvent = useFocusedSqValueKeyEvent(path); const unfocusedKeyEvent = useUnfocusedSqValueKeyEvent(path); @@ -139,8 +160,9 @@ export const ValueWithContextViewer: FC = ({ const taggedName = value.tags.name(); // root header is always hidden (unless forced, but we probably won't need it) - const header = props.header ?? (isRoot ? "hide" : "show"); - const collapsible = header === "hide" ? false : props.collapsible ?? true; + const headerVisibility = props.header ?? (isRoot ? "hide" : "show"); + const collapsible = + headerVisibility === "hide" ? false : props.collapsible ?? true; const size = props.size ?? "normal"; const enableDropdownMenu = viewerType !== "tooltip"; const enableFocus = viewerType !== "tooltip"; @@ -149,25 +171,16 @@ export const ValueWithContextViewer: FC = ({ toggleCollapsed_(path); }; - const ref = useRegisterAsItemViewer(path); - // TODO - check that we're not in a situation where `isOpen` is false and `header` is hidden? // In that case, the output would look broken (empty). const isOpen = !collapsible || !itemState.collapsed; useEffect(() => { - if (isFocused && !isRoot && ref.current) { - focusOnHeader(ref.current); + if (isFocused && !isRoot) { + handle.focusOnHeader(); } }, []); - const _focus = () => { - if (!enableFocus) { - return; - } - focus(path); - }; - const triangleToggle = () => { const Icon = itemState.collapsed ? CollapsedIcon : ExpandedIcon; const _hasExtraContentToShow = hasExtraContentToShow(value); @@ -193,7 +206,7 @@ export const ValueWithContextViewer: FC = ({ const name = pathToShortName(path); // We want to show colons after the keys, for dicts/arrays. - const showColon = header !== "large" && path.edges.length > 1; + const showColon = headerVisibility !== "large" && path.edges.length > 1; const getHeaderColor = () => { let color = "text-orange-900"; @@ -209,7 +222,7 @@ export const ValueWithContextViewer: FC = ({ const headerColor = getHeaderColor(); const headerClasses = () => { - if (header === "large") { + if (headerVisibility === "large") { return clsx("text-md font-bold", headerColor); } else if (isRoot) { return "text-sm text-stone-600 font-semibold"; @@ -226,7 +239,7 @@ export const ValueWithContextViewer: FC = ({
{taggedName || name} @@ -252,11 +265,18 @@ export const ValueWithContextViewer: FC = ({ } }; + useEffect(() => { + if (isFocused && !isRoot && headerRef && headerVisibility !== "hide") { + handle.focusOnHeader(); + } + }, []); + return ( -
- {header !== "hide" && ( +
+ {headerVisibility !== "hide" && (
void; -}; - type LocalItemState = Readonly<{ collapsed: boolean; calculator?: CalculatorState; @@ -83,7 +77,7 @@ type ValuePathUID = string; */ export class ItemStore { state: Record = {}; - handles: Record = {}; + handles: Record = {}; setState( path: SqValuePath, @@ -147,7 +141,7 @@ export class ItemStore { this.handles[path.uid()]?.forceUpdate(); } - registerItemHandle(path: SqValuePath, handle: ItemHandle) { + registerItemHandle(path: SqValuePath, handle: ValueWithContextViewerHandle) { this.handles[path.uid()] = handle; } @@ -170,9 +164,7 @@ export class ItemStore { } scrollViewerToPath(path: SqValuePath) { - this.handles[path.uid()]?.element.scrollIntoView({ - behavior: "smooth", - }); + this.handles[path.uid()]?.scrollIntoView(); } } @@ -215,8 +207,10 @@ export function useViewerContext() { // This allows us to do two things later: // 1. Implement `store.scrollViewerToPath`. // 2. Re-render individual item viewers on demand, for example on "Collapse Children" menu action. -export function useRegisterAsItemViewer(path: SqValuePath) { - const ref = useRef(null); +export function useRegisterAsItemViewer( + path: SqValuePath, + ref: ValueWithContextViewerHandle +) { const { itemStore } = useViewerContext(); /** @@ -224,20 +218,11 @@ export function useRegisterAsItemViewer(path: SqValuePath) { * So we use `forceUpdate` to force rerendering. * (This function is not used directly in this component. Instead, it's passed to `` to be called when necessary, sometimes from other components.) */ - const forceUpdate = useForceUpdate(); useEffect(() => { - const element = ref.current; - if (!element) { - return; - } - - itemStore.registerItemHandle(path, { element, forceUpdate }); - + itemStore.registerItemHandle(path, ref); return () => itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often }); - - return ref; } export function useSetLocalItemState() { diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index a395654de3..0f005f3595 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -1,10 +1,9 @@ import { SqValuePath } from "@quri/squiggle-lang"; -import { focusOnHeader } from "../ValueWithContextViewer.js"; import { ItemStore } from "../ViewerProvider.js"; export const focusSqValueHeader = (path: SqValuePath, itemStore: ItemStore) => { - focusOnHeader(itemStore.handles[path.uid()].element); + itemStore.handles[path.uid()]?.focusOnHeader(); }; export function isValidKey( From 81a32ba71740caa6915bb41f2f358d00cb227b71 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 18:44:19 -0800 Subject: [PATCH 26/36] Update packages/components/src/components/SquiggleViewer/SqViewNode.tsx Co-authored-by: Vyacheslav Matyukhin --- .../components/src/components/SquiggleViewer/SqViewNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx index c792688d99..8b7a95ce5f 100644 --- a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -41,7 +41,7 @@ class SqValueNode { ? new SqValueNode(this.root, path, this.traverseCalculatorEdge) : undefined; }) - .filter((a) => a !== undefined) as SqValueNode[]; + .filter((a): a is NonNullable => a !== undefined); } lastChild(): SqValueNode | undefined { From 232d5d0aba3626ba03ea2ab23d49a5a32962f375 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 19:14:28 -0800 Subject: [PATCH 27/36] Addressed most code parts of CR --- .../components/SquiggleViewer/SqViewNode.tsx | 4 +-- .../SquiggleViewer/ValueWithContextViewer.tsx | 16 ++++----- .../SquiggleViewer/ViewerProvider.tsx | 9 ++--- .../keyboardNav/focusedSqValue.ts | 10 +----- .../keyboardNav/unfocusedSqValue.ts | 11 +----- .../SquiggleViewer/keyboardNav/utils.ts | 35 ++++++------------- 6 files changed, 26 insertions(+), 59 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx index c792688d99..32e58e99e5 100644 --- a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -14,9 +14,9 @@ class SqValueNode { return this.path.uid(); } - isEqual = (other: SqValueNode): boolean => { + isEqual(other: SqValueNode): boolean { return this.uid() === other.uid(); - }; + } sqValue(): SqValue | undefined { return this.root.getSubvalueByPath(this.path, this.traverseCalculatorEdge); diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 5d473f7d12..f275a25191 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -116,6 +116,7 @@ export type ValueWithContextViewerHandle = { forceUpdate: () => void; scrollIntoView: () => void; focusOnHeader: () => void; + toggleCollapsed: () => void; }; export const ValueWithContextViewer: FC = ({ @@ -129,7 +130,9 @@ export const ValueWithContextViewer: FC = ({ const containerRef = useRef(null); const headerRef = useRef(null); - const handle = { + const toggleCollapsed_ = useToggleCollapsed(); + + const handle: ValueWithContextViewerHandle = { scrollIntoView: () => { containerRef?.current?.scrollIntoView({ behavior: "smooth", @@ -139,11 +142,11 @@ export const ValueWithContextViewer: FC = ({ focusOnHeader: () => { headerRef.current?.focus(); }, + toggleCollapsed: () => toggleCollapsed_(path), }; useRegisterAsItemViewer(path, handle); - const toggleCollapsed_ = useToggleCollapsed(); const _focus = useFocus(); const focus = () => enableFocus && _focus(path); const focusedKeyEvent = useFocusedSqValueKeyEvent(path); @@ -167,10 +170,6 @@ export const ValueWithContextViewer: FC = ({ const enableDropdownMenu = viewerType !== "tooltip"; const enableFocus = viewerType !== "tooltip"; - const toggleCollapsed = () => { - toggleCollapsed_(path); - }; - // TODO - check that we're not in a situation where `isOpen` is false and `header` is hidden? // In that case, the output would look broken (empty). const isOpen = !collapsible || !itemState.collapsed; @@ -192,7 +191,7 @@ export const ValueWithContextViewer: FC = ({ "w-4 mr-1.5 flex justify-center cursor-pointer hover:!text-stone-600", isOpen ? "text-stone-600 opacity-40" : "text-stone-800 opacity-40" )} - onClick={toggleCollapsed} + onClick={handle.toggleCollapsed} >
@@ -254,7 +253,7 @@ export const ValueWithContextViewer: FC = ({ return (
@@ -265,6 +264,7 @@ export const ValueWithContextViewer: FC = ({ } }; + //Focus on the header on mount if focused useEffect(() => { if (isFocused && !isRoot && headerRef && headerVisibility !== "hide") { handle.focusOnHeader(); diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index e165fbc44a..2a86132dab 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -221,7 +221,7 @@ export function useRegisterAsItemViewer( useEffect(() => { itemStore.registerItemHandle(path, ref); - return () => itemStore.unregisterItemHandle(path); // TODO: Seems to happen way too often + return () => itemStore.unregisterItemHandle(path); }); } @@ -381,11 +381,8 @@ export const InnerViewerProvider = forwardRef( useImperativeHandle(ref, () => handle); - const _rootValue = rootValue - ? valueHasContext(rootValue) - ? rootValue - : undefined - : undefined; + const _rootValue = + rootValue && valueHasContext(rootValue) ? rootValue : undefined; return ( { const newItem = findNode(selected)?.children()[0]; if (newItem) { diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts index 55d4295ea8..827278ea2e 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts @@ -3,19 +3,10 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { toggleCollapsed, useViewerContext } from "../ViewerProvider.js"; import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; -const validKeys = [ - "ArrowDown", - "ArrowUp", - "ArrowLeft", - "ArrowRight", - "Enter", - "e", -] as const; - export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { const { setFocused, itemStore, editor, findNode } = useViewerContext(); - return keyboardEventHandler(validKeys, { + return keyboardEventHandler({ ArrowDown: () => { const newPath = findNode(selected)?.next()?.node.path; newPath && focusSqValueHeader(newPath, itemStore); diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index 0f005f3595..329bd50d40 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -6,30 +6,17 @@ export const focusSqValueHeader = (path: SqValuePath, itemStore: ItemStore) => { itemStore.handles[path.uid()]?.focusOnHeader(); }; -export function isValidKey( - key: string, - validKeys: T -): key is T[number] { - return validKeys.includes(key as T[number]); -} - -type KeyHandler = (eventKey: string) => void; - // Returns boolean to indicate if the key was handled. The caller might want to do something else if it wasn't. -export function keyboardEventHandler( - validKeys: T, - handlers: Partial> +export function keyboardEventHandler( + handlers: Partial void>> ) { - return (event: React.KeyboardEvent) => - ((eventKey: string): boolean => { - if (isValidKey(eventKey, validKeys)) { - const handler = handlers[eventKey]; - event.preventDefault(); - if (handler) { - handler(eventKey); - } - return true; - } - return false; - })(event.key); + return (event: React.KeyboardEvent): boolean => { + const handler = handlers[event.key]; + if (handler) { + event.preventDefault(); + handler(); + return true; + } + return false; + }; } From 61df9e58d774628270c2034b46edaeb63442023d Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 20:36:47 -0800 Subject: [PATCH 28/36] Responded to other comments --- .../components/SquiggleViewer/SqViewNode.tsx | 4 +++- .../components/SquiggleViewer/ValueViewer.tsx | 9 ++++++- .../SquiggleViewer/ValueWithContextViewer.tsx | 1 + .../keyboardNav/focusedSqValue.ts | 24 +++++++++---------- .../keyboardNav/unfocusedSqValue.ts | 17 ++++++++++++- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx index 5708ce54c1..f855a4009c 100644 --- a/packages/components/src/components/SquiggleViewer/SqViewNode.tsx +++ b/packages/components/src/components/SquiggleViewer/SqViewNode.tsx @@ -8,7 +8,9 @@ class SqValueNode { public root: SqValue, public path: SqValuePath, public traverseCalculatorEdge: TraverseCalculatorEdge - ) {} + ) { + this.isEqual = this.isEqual.bind(this); + } uid() { return this.path.uid(); diff --git a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx index 065f534b70..ab45b74f01 100644 --- a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx @@ -16,5 +16,12 @@ export const ValueViewer: React.FC = ({ value, ...rest }) => { return ; } - return ; + // The key ID is needed to make sure that when open a nested value as Focused, it will get focused. + return ( + + ); }; diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index f275a25191..20e4153798 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -119,6 +119,7 @@ export type ValueWithContextViewerHandle = { toggleCollapsed: () => void; }; +// Note: When called, use a unique ``key``. Otherwise, the initial focus will not always work. export const ValueWithContextViewer: FC = ({ value, parentValue, diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts index 31e110d314..f0197e4c86 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts @@ -17,12 +17,18 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { return keyboardEventHandler({ ArrowDown: () => { - const newItem = findNode(selected)?.children()[0]; - if (newItem) { - focusSqValueHeader(newItem.node.path, itemStore); + const newPath = findNode(selected)?.nextSibling()?.node.path; + if (newPath) { + setFocused(newPath); } }, ArrowUp: () => { + const newPath = findNode(selected)?.prevSibling()?.node.path; + if (newPath) { + setFocused(newPath); + } + }, + ArrowLeft: () => { const newItem = findNode(selected)?.parent(); if (newItem) { if (newItem.isRoot()) { @@ -32,16 +38,10 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { } } }, - ArrowLeft: () => { - const newPath = findNode(selected)?.prevSibling()?.node.path; - if (newPath) { - setFocused(newPath); - } - }, ArrowRight: () => { - const newPath = findNode(selected)?.nextSibling()?.node.path; - if (newPath) { - setFocused(newPath); + const newItem = findNode(selected)?.children()[0]; + if (newItem) { + focusSqValueHeader(newItem.node.path, itemStore); } }, Enter: resetToRoot, diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts index 827278ea2e..a610986318 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts @@ -22,11 +22,26 @@ export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { focusSqValueHeader(newItem.node.path, itemStore); }, ArrowRight: () => { - toggleCollapsed(itemStore, selected); + const newItem = findNode(selected)?.children().at(0); + const isCollapsed = itemStore.state[selected.uid()]?.collapsed; + + if (newItem) { + if (isCollapsed) { + toggleCollapsed(itemStore, selected); + setTimeout(() => { + focusSqValueHeader(newItem.node.path, itemStore); + }, 1); + } else { + focusSqValueHeader(newItem.node.path, itemStore); + } + } }, Enter: () => { setFocused(selected); }, + " ": () => { + toggleCollapsed(itemStore, selected); + }, //e for "edit." Focuses the line and focuses it. e: () => { const value = findNode(selected)?.value(); From d9f008563456a06c5b95a9211517edc8051fac3a Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 22:19:46 -0800 Subject: [PATCH 29/36] Fixed styles --- packages/website/src/styles/main.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/website/src/styles/main.css b/packages/website/src/styles/main.css index 0c8495a714..2b32115d79 100644 --- a/packages/website/src/styles/main.css +++ b/packages/website/src/styles/main.css @@ -19,3 +19,14 @@ pre:not([data-theme]) { .prose pre:not([data-theme]) code { font-size: 0.85rem; } + +/* These styles were getting in the way of the playground focus elements. */ +[tabindex]:not([tabindex="-1"]):focus-visible +{ + --tw-ring-offset-shadow: none !important; +} + +button:focus-visible +{ + --tw-ring-offset-shadow: none !important; +} \ No newline at end of file From 22ee7f8c875d698a74623f05042d139765c79a86 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 22:48:30 -0800 Subject: [PATCH 30/36] Fixed weird focus for website, without important --- packages/website/src/styles/main.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/website/src/styles/main.css b/packages/website/src/styles/main.css index 2b32115d79..a91f604ef9 100644 --- a/packages/website/src/styles/main.css +++ b/packages/website/src/styles/main.css @@ -21,12 +21,12 @@ pre:not([data-theme]) { } /* These styles were getting in the way of the playground focus elements. */ -[tabindex]:not([tabindex="-1"]):focus-visible +[tabindex]:not([tabindex="-1"]):focus-visible:focus { - --tw-ring-offset-shadow: none !important; + @apply ring-0 ring-offset-0 } -button:focus-visible +button:focus-visible:focus { - --tw-ring-offset-shadow: none !important; + @apply ring-0 ring-offset-0 } \ No newline at end of file From 93b17ee6eb258463a84de4659741e9af5f57af1f Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Wed, 24 Jan 2024 22:49:50 -0800 Subject: [PATCH 31/36] Formatted CSS --- packages/website/src/styles/main.css | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/website/src/styles/main.css b/packages/website/src/styles/main.css index a91f604ef9..cb8f603de7 100644 --- a/packages/website/src/styles/main.css +++ b/packages/website/src/styles/main.css @@ -21,12 +21,10 @@ pre:not([data-theme]) { } /* These styles were getting in the way of the playground focus elements. */ -[tabindex]:not([tabindex="-1"]):focus-visible:focus -{ - @apply ring-0 ring-offset-0 +[tabindex]:not([tabindex="-1"]):focus-visible:focus { + @apply ring-0 ring-offset-0; } -button:focus-visible:focus -{ - @apply ring-0 ring-offset-0 -} \ No newline at end of file +button:focus-visible:focus { + @apply ring-0 ring-offset-0; +} From aae405373008879490af064eae80dac281e7a3c2 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 25 Jan 2024 08:35:56 -0800 Subject: [PATCH 32/36] Focus->Zoom In --- .../SquiggleViewer/SquiggleValueMenu.tsx | 20 +++---- .../SquiggleViewer/ValueWithContextViewer.tsx | 26 ++++----- .../SquiggleViewer/ViewerProvider.tsx | 39 +++++++------ .../src/components/SquiggleViewer/index.tsx | 57 ++++++++++--------- .../{focusedSqValue.ts => zoomedInSqValue.ts} | 18 +++--- ...nfocusedSqValue.ts => zoomedOutSqValue.ts} | 11 +++- .../src/widgets/TableChartWidget.tsx | 6 +- 7 files changed, 96 insertions(+), 81 deletions(-) rename packages/components/src/components/SquiggleViewer/keyboardNav/{focusedSqValue.ts => zoomedInSqValue.ts} (71%) rename packages/components/src/components/SquiggleViewer/keyboardNav/{unfocusedSqValue.ts => zoomedOutSqValue.ts} (87%) diff --git a/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx b/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx index 8e06f1281f..e4266a27fd 100644 --- a/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx +++ b/packages/components/src/components/SquiggleViewer/SquiggleValueMenu.tsx @@ -18,19 +18,19 @@ import { valueToHeadingString } from "../../widgets/utils.js"; import { CollapsedIcon, ExpandedIcon } from "./icons.js"; import { getChildrenValues } from "./utils.js"; import { - useFocus, useHasLocalSettings, - useIsFocused, + useIsZoomedIn, useSetCollapsed, - useUnfocus, useViewerContext, + useZoomIn, + useZoomOut, } from "./ViewerProvider.js"; const FocusItem: FC<{ value: SqValueWithContext }> = ({ value }) => { const { path } = value.context; - const isFocused = useIsFocused(path); - const focus = useFocus(); - const unfocus = useUnfocus(); + const isFocused = useIsZoomedIn(path); + const zoomIn = useZoomIn(); + const zoomOut = useZoomOut(); if (path.isRoot()) { return null; } @@ -38,17 +38,17 @@ const FocusItem: FC<{ value: SqValueWithContext }> = ({ value }) => { if (isFocused) { return ( ); } else { return ( focus(path)} + onClick={() => zoomIn(path)} /> ); } diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 20e4153798..79dd64bff2 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -12,8 +12,8 @@ import { MarkdownViewer } from "../../lib/MarkdownViewer.js"; import { SqValueWithContext } from "../../lib/utility.js"; import { ErrorBoundary } from "../ErrorBoundary.js"; import { CollapsedIcon, ExpandedIcon } from "./icons.js"; -import { useFocusedSqValueKeyEvent } from "./keyboardNav/focusedSqValue.js"; -import { useUnfocusedSqValueKeyEvent } from "./keyboardNav/unfocusedSqValue.js"; +import { useZoomedInSqValueKeyEvent } from "./keyboardNav/zoomedInSqValue.js"; +import { useZoomedOutSqValueKeyEvent } from "./keyboardNav/zoomedOutSqValue.js"; import { SquiggleValueChart } from "./SquiggleValueChart.js"; import { SquiggleValueMenu } from "./SquiggleValueMenu.js"; import { SquiggleValuePreview } from "./SquiggleValuePreview.js"; @@ -23,13 +23,13 @@ import { pathToShortName, } from "./utils.js"; import { - useFocus, useMergedSettings, useRegisterAsItemViewer, useScrollToEditorPath, useToggleCollapsed, useViewerContext, useViewerType, + useZoomIn, } from "./ViewerProvider.js"; const CommentIconForValue: FC<{ value: SqValueWithContext }> = ({ value }) => { @@ -148,16 +148,16 @@ export const ValueWithContextViewer: FC = ({ useRegisterAsItemViewer(path, handle); - const _focus = useFocus(); - const focus = () => enableFocus && _focus(path); - const focusedKeyEvent = useFocusedSqValueKeyEvent(path); - const unfocusedKeyEvent = useUnfocusedSqValueKeyEvent(path); + const zoomIn = useZoomIn(); + const focus = () => enableFocus && zoomIn(path); + const focusedKeyEvent = useZoomedInSqValueKeyEvent(path); + const unfocusedKeyEvent = useZoomedOutSqValueKeyEvent(path); const viewerType = useViewerType(); const scrollEditorToPath = useScrollToEditorPath(path); - const { itemStore, focused: _focused } = useViewerContext(); - const isFocused = _focused?.isEqual(path); + const { itemStore, zoomedInPath } = useViewerContext(); + const isZoomedIn = zoomedInPath?.isEqual(path); const itemState = itemStore.getStateOrInitialize(value); const isRoot = path.isRoot(); @@ -176,7 +176,7 @@ export const ValueWithContextViewer: FC = ({ const isOpen = !collapsible || !itemState.collapsed; useEffect(() => { - if (isFocused && !isRoot) { + if (isZoomedIn && !isRoot) { handle.focusOnHeader(); } }, []); @@ -267,7 +267,7 @@ export const ValueWithContextViewer: FC = ({ //Focus on the header on mount if focused useEffect(() => { - if (isFocused && !isRoot && headerRef && headerVisibility !== "hide") { + if (isZoomedIn && !isRoot && headerRef && headerVisibility !== "hide") { handle.focusOnHeader(); } }, []); @@ -281,7 +281,7 @@ export const ValueWithContextViewer: FC = ({ tabIndex={viewerType === "tooltip" ? undefined : 0} className={clsx( "flex justify-between group pr-0.5 hover:bg-stone-100 rounded-sm focus-visible:outline-none", - isFocused + isZoomedIn ? "focus:bg-indigo-50 mb-2 px-0.5 py-1" : "focus:bg-indigo-100" )} @@ -289,7 +289,7 @@ export const ValueWithContextViewer: FC = ({ scrollEditorToPath(); }} onKeyDown={(event) => { - isFocused ? focusedKeyEvent(event) : unfocusedKeyEvent(event); + isZoomedIn ? focusedKeyEvent(event) : unfocusedKeyEvent(event); }} >
diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index 2a86132dab..bc8d260603 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -173,8 +173,8 @@ type ViewerContextShape = { // Instead, we keep `localItemState` in local state and notify the global context via `setLocalItemState` to pass them down the component tree again if it got rebuilt from scratch. // See ./SquiggleViewer.tsx and ./ValueWithContextViewer.tsx for other implementation details on this. globalSettings: PlaygroundSettings; - focused: SqValuePath | undefined; - setFocused: (value: SqValuePath | undefined) => void; + zoomedInPath: SqValuePath | undefined; + setZoomedInPath: (value: SqValuePath | undefined) => void; editor?: CodeEditorHandle; itemStore: ItemStore; viewerType: ViewerType; @@ -186,8 +186,8 @@ type ViewerContextShape = { export const ViewerContext = createContext({ globalSettings: defaultPlaygroundSettings, - focused: undefined, - setFocused: () => undefined, + zoomedInPath: undefined, + setZoomedInPath: () => undefined, editor: undefined, itemStore: new ItemStore(), viewerType: "normal", @@ -283,23 +283,24 @@ export function useHasLocalSettings(path: SqValuePath) { ); } -export function useFocus() { - const { focused, setFocused } = useViewerContext(); +export function useZoomIn() { + const { zoomedInPath: zoomedInPath, setZoomedInPath: setZoomedInPath } = + useViewerContext(); return (path: SqValuePath) => { - if (focused?.isEqual(path)) { + if (zoomedInPath?.isEqual(path)) { return; // nothing to do } if (path.isRoot()) { - setFocused(undefined); // focusing on root nodes is not allowed + setZoomedInPath(undefined); // full screening on root nodes is not allowed } else { - setFocused(path); + setZoomedInPath(path); } }; } -export function useUnfocus() { - const { setFocused } = useViewerContext(); - return () => setFocused(undefined); +export function useZoomOut() { + const { setZoomedInPath: setZoomedInPath } = useViewerContext(); + return () => setZoomedInPath(undefined); } export function useScrollToEditorPath(path: SqValuePath) { @@ -316,9 +317,9 @@ export function useScrollToEditorPath(path: SqValuePath) { }; } -export function useIsFocused(path: SqValuePath) { - const { focused } = useViewerContext(); - return focused?.isEqual(path); +export function useIsZoomedIn(path: SqValuePath) { + const { zoomedInPath: zoomedInPath } = useViewerContext(); + return zoomedInPath?.isEqual(path); } export function useMergedSettings(path: SqValuePath) { @@ -367,7 +368,9 @@ export const InnerViewerProvider = forwardRef( unstablePlaygroundSettings ); - const [focused, setFocused] = useState(); + const [zoomedInPath, setZoomedInPathPath] = useState< + SqValuePath | undefined + >(); const globalSettings = useMemo(() => { return merge({}, defaultPlaygroundSettings, playgroundSettings); @@ -390,8 +393,8 @@ export const InnerViewerProvider = forwardRef( rootValue: _rootValue, globalSettings, editor, - focused, - setFocused, + zoomedInPath, + setZoomedInPath: setZoomedInPathPath, itemStore, viewerType, handle, diff --git a/packages/components/src/components/SquiggleViewer/index.tsx b/packages/components/src/components/SquiggleViewer/index.tsx index bc6e092419..479f67ee7b 100644 --- a/packages/components/src/components/SquiggleViewer/index.tsx +++ b/packages/components/src/components/SquiggleViewer/index.tsx @@ -10,13 +10,13 @@ import { useGetSubvalueByPath } from "./utils.js"; import { ValueViewer } from "./ValueViewer.js"; import { SquiggleViewerHandle, - useFocus, - useUnfocus, useViewerContext, + useZoomIn, + useZoomOut, ViewerProvider, } from "./ViewerProvider.js"; -const FocusedNavigationItem: FC<{ +const ZoomedInNavigationItem: FC<{ text: string; onClick: () => void; }> = ({ text, onClick }) => ( @@ -31,38 +31,38 @@ const FocusedNavigationItem: FC<{
); -const FocusedNavigation: FC<{ - focusedPath: SqValuePath; +const ZoomedInNavigation: FC<{ + zoomedInPath: SqValuePath; rootPath?: SqValuePath | undefined; -}> = ({ focusedPath, rootPath }) => { - const unfocus = useUnfocus(); - const focus = useFocus(); +}> = ({ zoomedInPath, rootPath }) => { + const zoomOut = useZoomOut(); + const zoomIn = useZoomIn(); - const isFocusedOnRootPath = rootPath && rootPath.isEqual(focusedPath); + const isZoomedInOnRootPath = rootPath && rootPath.isEqual(zoomedInPath); - if (isFocusedOnRootPath) { + if (isZoomedInOnRootPath) { return null; } - // If we're focused on the root path override, we need to adjust the focused path accordingly when presenting the navigation, so that it begins with the root path intead. This is a bit confusing. - const rootPathFocusedAdjustment = rootPath?.edges.length + // If we're zoomedIn on the root path override, we need to adjust the zoomedIn path accordingly when presenting the navigation, so that it begins with the root path intead. This is a bit confusing. + const rootPathZoomedInAdjustment = rootPath?.edges.length ? rootPath.edges.length - 1 : 0; return (
{!rootPath?.edges.length && ( - + )} - {focusedPath + {zoomedInPath .allPrefixPaths({ includeRoot: false }) - .slice(rootPathFocusedAdjustment, -1) + .slice(rootPathZoomedInAdjustment, -1) .map((path, i) => ( - focus(path)} - text={path.edges[i + rootPathFocusedAdjustment].toDisplayString()} + onClick={() => zoomIn(path)} + text={path.edges[i + rootPathZoomedInAdjustment].toDisplayString()} /> ))}
@@ -75,27 +75,30 @@ export type SquiggleViewerProps = { } & PartialPlaygroundSettings; const SquiggleViewerWithoutProvider: FC = ({ value }) => { - const { focused } = useViewerContext(); + const { zoomedInPath } = useViewerContext(); const getSubvalueByPath = useGetSubvalueByPath(); - let focusedItem: SqValue | undefined; - if (focused) { - focusedItem = getSubvalueByPath(value, focused); + let zoomedInItem: SqValue | undefined; + if (zoomedInPath) { + zoomedInItem = getSubvalueByPath(value, zoomedInPath); } - return focused ? ( + return zoomedInPath ? (
- - {focusedItem ? ( + + {zoomedInItem ? ( ) : ( - + )}
) : ( diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts similarity index 71% rename from packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts rename to packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts index f0197e4c86..9f3ad279df 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/focusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts @@ -3,13 +3,17 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { useViewerContext } from "../ViewerProvider.js"; import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; -export function useFocusedSqValueKeyEvent(selected: SqValuePath) { - const { setFocused, itemStore, findNode } = useViewerContext(); +export function useZoomedInSqValueKeyEvent(selected: SqValuePath) { + const { + setZoomedInPath: setZoomedInPath, + itemStore, + findNode, + } = useViewerContext(); function resetToRoot() { - setFocused(undefined); + setZoomedInPath(undefined); - // This timeout is a hack to make sure the header is focused after the reset + // This timeout is a hack to make sure the header is zoomedIn after the reset setTimeout(() => { focusSqValueHeader(selected, itemStore); }, 1); @@ -19,13 +23,13 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { ArrowDown: () => { const newPath = findNode(selected)?.nextSibling()?.node.path; if (newPath) { - setFocused(newPath); + setZoomedInPath(newPath); } }, ArrowUp: () => { const newPath = findNode(selected)?.prevSibling()?.node.path; if (newPath) { - setFocused(newPath); + setZoomedInPath(newPath); } }, ArrowLeft: () => { @@ -34,7 +38,7 @@ export function useFocusedSqValueKeyEvent(selected: SqValuePath) { if (newItem.isRoot()) { resetToRoot(); } else { - setFocused(newItem.node.path); + setZoomedInPath(newItem.node.path); } } }, diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts similarity index 87% rename from packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts rename to packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts index a610986318..188f8f07b2 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/unfocusedSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts @@ -3,8 +3,13 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { toggleCollapsed, useViewerContext } from "../ViewerProvider.js"; import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; -export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { - const { setFocused, itemStore, editor, findNode } = useViewerContext(); +export function useZoomedOutSqValueKeyEvent(selected: SqValuePath) { + const { + setZoomedInPath: setZoomedInPath, + itemStore, + editor, + findNode, + } = useViewerContext(); return keyboardEventHandler({ ArrowDown: () => { @@ -37,7 +42,7 @@ export function useUnfocusedSqValueKeyEvent(selected: SqValuePath) { } }, Enter: () => { - setFocused(selected); + setZoomedInPath(selected); }, " ": () => { toggleCollapsed(itemStore, selected); diff --git a/packages/components/src/widgets/TableChartWidget.tsx b/packages/components/src/widgets/TableChartWidget.tsx index 1a6128a3e3..27a43a4c39 100644 --- a/packages/components/src/widgets/TableChartWidget.tsx +++ b/packages/components/src/widgets/TableChartWidget.tsx @@ -5,7 +5,7 @@ import { TableCellsIcon } from "@quri/ui"; import { PlaygroundSettings } from "../components/PlaygroundSettings.js"; import { SquiggleValueChart } from "../components/SquiggleViewer/SquiggleValueChart.js"; -import { useFocus } from "../components/SquiggleViewer/ViewerProvider.js"; +import { useZoomIn } from "../components/SquiggleViewer/ViewerProvider.js"; import { valueHasContext } from "../lib/utility.js"; import { widgetRegistry } from "./registry.js"; @@ -23,7 +23,7 @@ widgetRegistry.register("TableChart", { Chart: (valueWithContext, settings) => { const environment = valueWithContext.context.project.getEnvironment(); const value = valueWithContext.value; - const focus = useFocus(); + const zoomedIn = useZoomIn(); const rowsAndColumns = value.items(environment); const columnNames = value.columnNames; const hasColumnNames = columnNames.filter((name) => !!name).length > 0; @@ -92,7 +92,7 @@ widgetRegistry.register("TableChart", { if (event.key === "Enter" && item.ok) { event.preventDefault(); const path = item.value.context?.path; - path && focus(path); + path && zoomedIn(path); } }} className={clsx( From 25e76d452fe480cdd27f7153848558659a9f85a6 Mon Sep 17 00:00:00 2001 From: Ozzie Gooen Date: Thu, 25 Jan 2024 10:18:57 -0800 Subject: [PATCH 33/36] Adds changeset --- .changeset/strong-balloons-rush.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/strong-balloons-rush.md diff --git a/.changeset/strong-balloons-rush.md b/.changeset/strong-balloons-rush.md new file mode 100644 index 0000000000..3a1618538a --- /dev/null +++ b/.changeset/strong-balloons-rush.md @@ -0,0 +1,6 @@ +--- +"@quri/squiggle-lang": patch +"@quri/squiggle-components": patch +--- + +Adds simple keyboard navigation for Viewer From 7d04a7b9c1cac80734e7109adffbeb9e13026937 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Thu, 25 Jan 2024 13:52:06 -0600 Subject: [PATCH 34/36] refactor auto-focus zoomed in values --- .../SquiggleViewer/ValueWithContextViewer.tsx | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx index 79dd64bff2..d1ffcaf67d 100644 --- a/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueWithContextViewer.tsx @@ -2,7 +2,7 @@ import "../../widgets/index.js"; import { clsx } from "clsx"; -import { FC, PropsWithChildren, useEffect, useMemo, useRef } from "react"; +import { FC, PropsWithChildren, useCallback, useMemo, useRef } from "react"; import { SqValue } from "@quri/squiggle-lang"; import { CommentIcon, TextTooltip } from "@quri/ui"; @@ -129,10 +129,15 @@ export const ValueWithContextViewer: FC = ({ const { path } = value.context; const containerRef = useRef(null); - const headerRef = useRef(null); + const headerRef = useRef(null); const toggleCollapsed_ = useToggleCollapsed(); + // Identity must be stable for the sake of `setHeaderRef` callback + const focusOnHeader = useCallback(() => { + headerRef.current?.focus(); + }, []); + const handle: ValueWithContextViewerHandle = { scrollIntoView: () => { containerRef?.current?.scrollIntoView({ @@ -140,9 +145,7 @@ export const ValueWithContextViewer: FC = ({ }); }, forceUpdate: useForceUpdate(), - focusOnHeader: () => { - headerRef.current?.focus(); - }, + focusOnHeader, toggleCollapsed: () => toggleCollapsed_(path), }; @@ -175,12 +178,6 @@ export const ValueWithContextViewer: FC = ({ // In that case, the output would look broken (empty). const isOpen = !collapsible || !itemState.collapsed; - useEffect(() => { - if (isZoomedIn && !isRoot) { - handle.focusOnHeader(); - } - }, []); - const triangleToggle = () => { const Icon = itemState.collapsed ? CollapsedIcon : ExpandedIcon; const _hasExtraContentToShow = hasExtraContentToShow(value); @@ -265,19 +262,25 @@ export const ValueWithContextViewer: FC = ({ } }; - //Focus on the header on mount if focused - useEffect(() => { - if (isZoomedIn && !isRoot && headerRef && headerVisibility !== "hide") { - handle.focusOnHeader(); - } - }, []); + // Store the header reference for the future `focusOnHeader()` handle, and auto-focus zoomed in values on mount. + const setHeaderRef = useCallback( + (el: HTMLElement | null) => { + headerRef.current = el; + + // If `isZoomedIn` toggles from `false` to `true`, this callback identity will change and it will update the focus. + if (isZoomedIn) { + focusOnHeader(); + } + }, + [isZoomedIn, focusOnHeader] + ); return (
{headerVisibility !== "hide" && (
Date: Thu, 25 Jan 2024 13:53:27 -0600 Subject: [PATCH 35/36] no need for custom `key` --- .../src/components/SquiggleViewer/ValueViewer.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx index ab45b74f01..065f534b70 100644 --- a/packages/components/src/components/SquiggleViewer/ValueViewer.tsx +++ b/packages/components/src/components/SquiggleViewer/ValueViewer.tsx @@ -16,12 +16,5 @@ export const ValueViewer: React.FC = ({ value, ...rest }) => { return ; } - // The key ID is needed to make sure that when open a nested value as Focused, it will get focused. - return ( - - ); + return ; }; From da41d3e1d7600b20cc32f6c97538e8df740d0c05 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Thu, 25 Jan 2024 14:12:58 -0600 Subject: [PATCH 36/36] move focusOnPath to ItemStore --- .../components/SquiggleViewer/ViewerProvider.tsx | 4 ++++ .../components/SquiggleViewer/keyboardNav/utils.ts | 8 -------- .../SquiggleViewer/keyboardNav/zoomedInSqValue.ts | 6 +++--- .../SquiggleViewer/keyboardNav/zoomedOutSqValue.ts | 14 ++++++-------- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx index bc8d260603..70f8b22a74 100644 --- a/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx +++ b/packages/components/src/components/SquiggleViewer/ViewerProvider.tsx @@ -166,6 +166,10 @@ export class ItemStore { scrollViewerToPath(path: SqValuePath) { this.handles[path.uid()]?.scrollIntoView(); } + + focusOnPath(path: SqValuePath) { + this.handles[path.uid()]?.focusOnHeader(); + } } type ViewerContextShape = { diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts index 329bd50d40..279c3ffc62 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/utils.ts @@ -1,11 +1,3 @@ -import { SqValuePath } from "@quri/squiggle-lang"; - -import { ItemStore } from "../ViewerProvider.js"; - -export const focusSqValueHeader = (path: SqValuePath, itemStore: ItemStore) => { - itemStore.handles[path.uid()]?.focusOnHeader(); -}; - // Returns boolean to indicate if the key was handled. The caller might want to do something else if it wasn't. export function keyboardEventHandler( handlers: Partial void>> diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts index 9f3ad279df..1623e8e830 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedInSqValue.ts @@ -1,7 +1,7 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { useViewerContext } from "../ViewerProvider.js"; -import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; +import { keyboardEventHandler } from "./utils.js"; export function useZoomedInSqValueKeyEvent(selected: SqValuePath) { const { @@ -15,7 +15,7 @@ export function useZoomedInSqValueKeyEvent(selected: SqValuePath) { // This timeout is a hack to make sure the header is zoomedIn after the reset setTimeout(() => { - focusSqValueHeader(selected, itemStore); + itemStore.focusOnPath(selected); }, 1); } @@ -45,7 +45,7 @@ export function useZoomedInSqValueKeyEvent(selected: SqValuePath) { ArrowRight: () => { const newItem = findNode(selected)?.children()[0]; if (newItem) { - focusSqValueHeader(newItem.node.path, itemStore); + itemStore.focusOnPath(newItem.node.path); } }, Enter: resetToRoot, diff --git a/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts index 188f8f07b2..f3b5c37cf2 100644 --- a/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts +++ b/packages/components/src/components/SquiggleViewer/keyboardNav/zoomedOutSqValue.ts @@ -1,7 +1,7 @@ import { SqValuePath } from "@quri/squiggle-lang"; import { toggleCollapsed, useViewerContext } from "../ViewerProvider.js"; -import { focusSqValueHeader, keyboardEventHandler } from "./utils.js"; +import { keyboardEventHandler } from "./utils.js"; export function useZoomedOutSqValueKeyEvent(selected: SqValuePath) { const { @@ -14,17 +14,15 @@ export function useZoomedOutSqValueKeyEvent(selected: SqValuePath) { return keyboardEventHandler({ ArrowDown: () => { const newPath = findNode(selected)?.next()?.node.path; - newPath && focusSqValueHeader(newPath, itemStore); + newPath && itemStore.focusOnPath(newPath); }, ArrowUp: () => { const newPath = findNode(selected)?.prev()?.node.path; - newPath && focusSqValueHeader(newPath, itemStore); + newPath && itemStore.focusOnPath(newPath); }, ArrowLeft: () => { const newItem = findNode(selected)?.parent(); - newItem && - !newItem.isRoot() && - focusSqValueHeader(newItem.node.path, itemStore); + newItem && !newItem.isRoot() && itemStore.focusOnPath(newItem.node.path); }, ArrowRight: () => { const newItem = findNode(selected)?.children().at(0); @@ -34,10 +32,10 @@ export function useZoomedOutSqValueKeyEvent(selected: SqValuePath) { if (isCollapsed) { toggleCollapsed(itemStore, selected); setTimeout(() => { - focusSqValueHeader(newItem.node.path, itemStore); + itemStore.focusOnPath(newItem.node.path); }, 1); } else { - focusSqValueHeader(newItem.node.path, itemStore); + itemStore.focusOnPath(newItem.node.path); } } },