From af53dfd1406cc723a35d614130c1b96404d80490 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 15:52:52 +0100 Subject: [PATCH 01/10] ENH: Hierarchical FocusHandle --- .../frontend/src/analysis/analysis_editor.tsx | 8 +- .../src/components/document_picker.tsx | 9 +- .../frontend/src/diagram/diagram_editor.tsx | 8 +- .../src/diagram/morphism_cell_editor.tsx | 53 +++----- .../src/diagram/object_cell_editor.tsx | 29 ++--- .../src/model/contribution_cell_editor.tsx | 48 +++---- .../model/contribution_monomial_editor.tsx | 6 +- packages/frontend/src/model/editors.ts | 5 +- .../src/model/instantiation_cell_editor.tsx | 123 ++++++------------ packages/frontend/src/model/model_editor.tsx | 10 +- .../src/model/morphism_cell_editor.tsx | 52 +++----- .../frontend/src/model/object_cell_editor.tsx | 3 +- .../frontend/src/model/object_list_editor.tsx | 39 +++--- .../string_diagram_morphism_cell_editor.tsx | 36 ++--- .../frontend/src/notebook/notebook_cell.tsx | 18 +-- .../frontend/src/notebook/notebook_editor.tsx | 59 ++++----- packages/frontend/src/page/document_page.tsx | 39 +++++- packages/ui-components/src/index.ts | 1 + packages/ui-components/src/text_input.tsx | 8 +- packages/ui-components/src/util/focus.ts | 45 +++++++ 20 files changed, 295 insertions(+), 304 deletions(-) create mode 100644 packages/ui-components/src/util/focus.ts diff --git a/packages/frontend/src/analysis/analysis_editor.tsx b/packages/frontend/src/analysis/analysis_editor.tsx index bbd48238c..74888b8ba 100644 --- a/packages/frontend/src/analysis/analysis_editor.tsx +++ b/packages/frontend/src/analysis/analysis_editor.tsx @@ -3,6 +3,7 @@ import { Dynamic } from "solid-js/web"; import invariant from "tiny-invariant"; import { Nb } from "catcolab-document-methods"; +import { type FocusHandle } from "catcolab-ui-components"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import type { AnalysisMeta, DiagramAnalysisMeta, ModelAnalysisMeta } from "../theory"; import { LiveAnalysisContext } from "./context"; @@ -16,7 +17,10 @@ import type { Analysis } from "./types"; /** Notebook editor for analyses of models of double theories. */ -export function AnalysisNotebookEditor(props: { liveAnalysis: LiveAnalysisDoc }) { +export function AnalysisNotebookEditor(props: { + liveAnalysis: LiveAnalysisDoc; + focus: FocusHandle; +}) { const liveDoc = () => props.liveAnalysis.liveDoc; const cellConstructors = () => { @@ -39,7 +43,7 @@ export function AnalysisNotebookEditor(props: { liveAnalysis: LiveAnalysisDoc }) changeNotebook={(f) => liveDoc().changeDoc((doc) => f(doc.notebook))} formalCellEditor={AnalysisCellEditor} cellConstructors={cellConstructors()} - noShortcuts={true} + focus={props.focus} /> ); diff --git a/packages/frontend/src/components/document_picker.tsx b/packages/frontend/src/components/document_picker.tsx index d049e07cb..ad976b452 100644 --- a/packages/frontend/src/components/document_picker.tsx +++ b/packages/frontend/src/components/document_picker.tsx @@ -55,6 +55,7 @@ export function DocumentPicker( "docType", "placeholder", "isActive", + "focus", "filterCompletions", ]); @@ -68,10 +69,13 @@ export function DocumentPicker( ); const [editMode, setEditMode] = createSignal(false); - const enableEditMode = () => setEditMode(true); + const enableEditMode = () => { + props.focus?.setFocused(true); + setEditMode(true); + }; const disableEditMode = () => setEditMode(false); - createEffect(() => setEditMode(props.isActive ?? false)); + createEffect(() => setEditMode(props.focus?.hasFocus() ?? props.isActive ?? false)); const DocLink = (linkProps: ComponentProps<"a">) => ( @@ -123,6 +127,7 @@ export function DocumentPicker( }> { props.setRefId(refId); diff --git a/packages/frontend/src/diagram/diagram_editor.tsx b/packages/frontend/src/diagram/diagram_editor.tsx index e4ded5756..12e3e93ab 100644 --- a/packages/frontend/src/diagram/diagram_editor.tsx +++ b/packages/frontend/src/diagram/diagram_editor.tsx @@ -4,6 +4,7 @@ import invariant from "tiny-invariant"; import { Diagram, Nb } from "catcolab-document-methods"; import type { DiagramJudgment, DiagramMorDecl, DiagramObDecl } from "catcolab-document-types"; +import { type FocusHandle } from "catcolab-ui-components"; import { LiveModelContext } from "../model"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import type { InstanceTypeMeta } from "../theory"; @@ -14,7 +15,7 @@ import { DiagramObjectCellEditor } from "./object_cell_editor"; /** Notebook editor for a diagram in a model. */ -export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc }) { +export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc; focus: FocusHandle }) { const liveDoc = () => props.liveDiagram.liveDoc; const liveModel = () => props.liveDiagram.liveModel; @@ -39,6 +40,7 @@ export function DiagramNotebookEditor(props: { liveDiagram: LiveDiagramDoc }) { cellConstructors={cellConstructors()} cellLabel={judgmentLabel} duplicateCell={Diagram.duplicateDiagramJudgment} + focus={props.focus} /> ); @@ -59,7 +61,7 @@ function DiagramCellEditor(props: FormalCellEditorProps) { modifyDecl={(f) => props.changeContent((content) => f(content as DiagramObDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -72,7 +74,7 @@ function DiagramCellEditor(props: FormalCellEditorProps) { modifyDecl={(f) => props.changeContent((content) => f(content as DiagramMorDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> diff --git a/packages/frontend/src/diagram/morphism_cell_editor.tsx b/packages/frontend/src/diagram/morphism_cell_editor.tsx index 481291f77..bd698c0d3 100644 --- a/packages/frontend/src/diagram/morphism_cell_editor.tsx +++ b/packages/frontend/src/diagram/morphism_cell_editor.tsx @@ -1,7 +1,8 @@ -import { createEffect, createSignal, useContext } from "solid-js"; +import { useContext } from "solid-js"; import invariant from "tiny-invariant"; import { v7 } from "uuid"; +import { type FocusHandle, useChildFocus } from "catcolab-ui-components"; import type { DiagramMorDecl } from "catlog-wasm"; import { BasicMorInput } from "../model/morphism_input"; import type { CellActions } from "../notebook"; @@ -16,21 +17,15 @@ import "./morphism_cell_editor.css"; export function DiagramMorphismCellEditor(props: { decl: DiagramMorDecl; modifyDecl: (f: (decl: DiagramMorDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }) { const liveDiagram = useContext(LiveDiagramContext); invariant(liveDiagram, "Live diagram should be provided as context"); - const [activeInput, setActiveInput] = createSignal("mor"); - - // Reset to default on deactivation so re-entry lands on the morphism input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("mor"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "mor" }); const domType = () => props.theory.theory.src(props.decl.morType); const codType = () => props.theory.theory.tgt(props.decl.morType); @@ -61,15 +56,11 @@ export function DiagramMorphismCellEditor(props: { obType={domType()} generateId={v7} isInvalid={domInvalid()} - isActive={props.isActive && activeInput() === "dom"} - deleteForward={() => setActiveInput("mor")} - exitBackward={() => setActiveInput("mor")} - exitForward={() => setActiveInput("cod")} - exitRight={() => setActiveInput("mor")} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} + focus={focus.childFocus("dom")} + deleteForward={() => focus.setActiveChild("mor")} + exitBackward={() => focus.setActiveChild("mor")} + exitForward={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("mor")} />
@@ -82,19 +73,15 @@ export function DiagramMorphismCellEditor(props: { }} morType={props.decl.morType} placeholder={props.theory.modelMorTypeMeta(props.decl.morType)?.name} - isActive={props.isActive && activeInput() === "mor"} + focus={focus.childFocus("mor")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} + exitForward={() => focus.setActiveChild("dom")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("dom")} - exitRight={() => setActiveInput("cod")} - hasFocused={() => { - setActiveInput("mor"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("dom")} + exitRight={() => focus.setActiveChild("cod")} />
@@ -112,15 +99,11 @@ export function DiagramMorphismCellEditor(props: { obType={codType()} generateId={v7} isInvalid={codInvalid()} - isActive={props.isActive && activeInput() === "cod"} - deleteBackward={() => setActiveInput("mor")} - exitBackward={() => setActiveInput("dom")} + focus={focus.childFocus("cod")} + deleteBackward={() => focus.setActiveChild("mor")} + exitBackward={() => focus.setActiveChild("dom")} exitForward={props.actions.activateBelow} - exitLeft={() => setActiveInput("mor")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("mor")} />
); diff --git a/packages/frontend/src/diagram/object_cell_editor.tsx b/packages/frontend/src/diagram/object_cell_editor.tsx index 9fab4fe87..7f00403de 100644 --- a/packages/frontend/src/diagram/object_cell_editor.tsx +++ b/packages/frontend/src/diagram/object_cell_editor.tsx @@ -1,6 +1,4 @@ -import { createSignal } from "solid-js"; - -import { NameInput } from "catcolab-ui-components"; +import { NameInput, type FocusHandle, useChildFocus } from "catcolab-ui-components"; import type { DiagramObDecl } from "catlog-wasm"; import { ObInput } from "../model/object_input"; import type { CellActions } from "../notebook"; @@ -12,11 +10,12 @@ import "./object_cell_editor.css"; export function DiagramObjectCellEditor(props: { decl: DiagramObDecl; modifyDecl: (f: (decl: DiagramObDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }) { - const [activeInput, setActiveInput] = createSignal("name"); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); return (
@@ -32,13 +31,9 @@ export function DiagramObjectCellEditor(props: { deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitRight={() => setActiveInput("overOb")} - exitForward={() => setActiveInput("overOb")} - isActive={props.isActive && activeInput() === "name"} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitRight={() => focus.setActiveChild("overOb")} + exitForward={() => focus.setActiveChild("overOb")} + focus={focus.childFocus("name")} /> setActiveInput("name")} - exitBackward={() => setActiveInput("name")} - isActive={props.isActive && activeInput() === "overOb"} - hasFocused={() => { - setActiveInput("overOb"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + focus={focus.childFocus("overOb")} />
); diff --git a/packages/frontend/src/model/contribution_cell_editor.tsx b/packages/frontend/src/model/contribution_cell_editor.tsx index 673b48434..7e5d35149 100644 --- a/packages/frontend/src/model/contribution_cell_editor.tsx +++ b/packages/frontend/src/model/contribution_cell_editor.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, createSignal, useContext, Switch, Match } from "solid-js"; +import { createMemo, useContext, Switch, Match } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { NameInput, useChildFocus } from "catcolab-ui-components"; import type { Ob } from "catlog-wasm"; import { LiveModelContext } from "./context"; import { ContributionMonomialEditor } from "./contribution_monomial_editor"; @@ -32,14 +32,8 @@ export default function ContributionCellEditor( const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeInput, setActiveInput] = createSignal("name"); - - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("name"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); const morTypeMeta = () => props.theory.modelMorTypeMeta(props.morphism.morType); @@ -104,19 +98,15 @@ export default function ContributionCellEditor( mor.name = name; }); }} - isActive={props.isActive && activeInput() === "name"} + focus={focus.childFocus("name")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("cod")} + exitForward={() => focus.setActiveChild("cod")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("cod")} - exitRight={() => setActiveInput("dom")} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("dom")} />
:
@@ -138,15 +128,11 @@ export default function ContributionCellEditor( obType={codType()} applyOp={morTypeMeta()?.codomain?.apply} isInvalid={errors().some((err) => err.tag === "Cod" || err.tag === "CodType")} - isActive={props.isActive && activeInput() === "cod"} - deleteForward={() => setActiveInput("name")} + focus={focus.childFocus("cod")} + deleteForward={() => focus.setActiveChild("name")} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} - exitLeft={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitForward={() => focus.setActiveChild("dom")} + exitLeft={() => focus.setActiveChild("name")} />
@@ -164,15 +150,11 @@ export default function ContributionCellEditor( setOb={setDomOb} obType={domType()} isInvalid={errors().some((err) => err.tag === "Dom" || err.tag === "DomType")} - isActive={props.isActive && activeInput() === "dom"} - deleteBackward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("name")} + focus={focus.childFocus("dom")} + deleteBackward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} exitForward={props.actions.activateBelow} exitRight={props.actions.activateBelow} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} />
diff --git a/packages/frontend/src/model/contribution_monomial_editor.tsx b/packages/frontend/src/model/contribution_monomial_editor.tsx index 25d3680da..0c2343637 100644 --- a/packages/frontend/src/model/contribution_monomial_editor.tsx +++ b/packages/frontend/src/model/contribution_monomial_editor.tsx @@ -59,11 +59,12 @@ export function ContributionMonomialEditor(props: ContributionMonomialEditorProp return ( ob === null)} + when={(props.focus?.hasFocus() ?? props.isActive) || obList().some((ob) => ob === null)} fallback={
{ + props.focus?.setFocused(true); props.hasFocused?.(); evt.preventDefault(); }} @@ -91,14 +92,13 @@ export function ContributionMonomialEditor(props: ContributionMonomialEditorProp obType={props.obType} placeholder={props.placeholder} isInvalid={props.isInvalid} - isActive={props.isActive} + focus={props.focus} deleteBackward={props.deleteBackward} deleteForward={props.deleteForward} exitBackward={props.exitBackward} exitForward={props.exitForward} exitLeft={props.exitLeft} exitRight={props.exitRight} - hasFocused={props.hasFocused} insertKey={props.insertKey ?? ","} startDelimiter={
{"["}
} endDelimiter={
{"]"}
} diff --git a/packages/frontend/src/model/editors.ts b/packages/frontend/src/model/editors.ts index 516f19850..131d8595a 100644 --- a/packages/frontend/src/model/editors.ts +++ b/packages/frontend/src/model/editors.ts @@ -1,5 +1,6 @@ import type { Component } from "solid-js"; +import type { FocusHandle } from "catcolab-ui-components"; import type { MorDecl, ObDecl } from "catlog-wasm"; import type { CellActions } from "../notebook"; import type { Theory } from "../theory"; @@ -19,7 +20,7 @@ export type EditorVariantOverrides = { export type ObjectEditorProps = { object: ObDecl; modifyObject: (f: (decl: ObDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }; @@ -28,7 +29,7 @@ export type ObjectEditorProps = { export type MorphismEditorProps = { morphism: MorDecl; modifyMorphism: (f: (decl: MorDecl) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; theory: Theory; }; diff --git a/packages/frontend/src/model/instantiation_cell_editor.tsx b/packages/frontend/src/model/instantiation_cell_editor.tsx index d76331cc9..09f4c1916 100644 --- a/packages/frontend/src/model/instantiation_cell_editor.tsx +++ b/packages/frontend/src/model/instantiation_cell_editor.tsx @@ -1,17 +1,13 @@ import type { DocInfo } from "catcolab-api/src/user_state"; -import { - batch, - createEffect, - createSignal, - Index, - Show, - splitProps, - untrack, - useContext, -} from "solid-js"; +import { batch, createEffect, Index, Show, splitProps, untrack, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput, type TextInputOptions } from "catcolab-ui-components"; +import { + type FocusHandle, + NameInput, + type TextInputOptions, + useChildFocus, +} from "catcolab-ui-components"; import type { DblModel, InstantiatedModel, Ob, SpecializeModel } from "catlog-wasm"; import { useApi } from "../api"; import { DocumentPicker, IdInput, IdInputPlaceholder } from "../components"; @@ -27,7 +23,7 @@ import "./instantiation_cell_editor.css"; export function InstantiationCellEditor(props: { instantiation: InstantiatedModel; modifyInstantiation: (f: (inst: InstantiatedModel) => void) => void; - isActive: boolean; + focus: FocusHandle; actions: CellActions; }) { const api = useApi(); @@ -67,26 +63,19 @@ export function InstantiationCellEditor(props: { invariant(models); const instantiated = models.useElaboratedModel(refId); - const [activeComponent, setActiveComponent] = createSignal( - refId() == null ? "model" : "name", - ); - const [activeIndex, setActiveIndex] = createSignal(0); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { + default: refId() == null ? "model" : "name", + }); + const specializationFocus = useChildFocus(focus.childFocus("specializations"), { + default: 0, + }); const activateIndex = (index: number) => batch(() => { - setActiveComponent("specializations"); - setActiveIndex(index); + focus.setActiveChild("specializations"); + specializationFocus.setActiveChild(index); }); - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - batch(() => { - setActiveComponent("name"); - setActiveIndex(0); - }); - } - }); - const insertSpecializationAtTop = () => { props.modifyInstantiation((inst) => { inst.specializations.unshift({ id: null, ob: null }); @@ -156,7 +145,7 @@ export function InstantiationCellEditor(props: { // Clean up empty rows when the cell becomes inactive. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { untrack(() => pruneEmptySpecializations()); } }); @@ -185,13 +174,9 @@ export function InstantiationCellEditor(props: { deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={exitDownFromTop} - exitRight={() => setActiveComponent("model")} - exitForward={() => setActiveComponent("model")} - isActive={props.isActive && activeComponent() === "name"} - hasFocused={() => { - setActiveComponent("name"); - props.actions.hasFocused(); - }} + exitRight={() => focus.setActiveChild("model")} + exitForward={() => focus.setActiveChild("model")} + focus={focus.childFocus("name")} /> setActiveComponent("name")} + deleteBackward={() => focus.setActiveChild("name")} exitUp={props.actions.activateAbove} exitDown={exitDownFromTop} - exitLeft={() => setActiveComponent("name")} - exitBackward={() => setActiveComponent("name")} - isActive={props.isActive && activeComponent() === "model"} - hasFocused={() => { - setActiveComponent("model"); - props.actions.hasFocused(); - }} + exitLeft={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + focus={focus.childFocus("model")} />
    idInputTexts.set(i, text)} instantiatedModel={instantiated()?.validatedModel.model ?? null} - isActive={ - props.isActive && - activeComponent() === "specializations" && - activeIndex() === i - } - hasFocused={() => { - activateIndex(i); - props.actions.hasFocused(); - }} + focus={specializationFocus.childFocus(i)} createBelow={() => { props.modifyInstantiation((inst) => { const spec = { id: null, ob: null }; @@ -262,7 +235,7 @@ export function InstantiationCellEditor(props: { props.modifyInstantiation((inst) => inst.specializations.splice(i, 1), ); - i === 0 ? setActiveComponent("name") : activateIndex(i - 1); + i === 0 ? focus.setActiveChild("name") : activateIndex(i - 1); }} exitDown={() => { if (i >= props.instantiation.specializations.length - 1) { @@ -272,7 +245,7 @@ export function InstantiationCellEditor(props: { } }} exitUp={() => { - i === 0 ? setActiveComponent("name") : activateIndex(i - 1); + i === 0 ? focus.setActiveChild("name") : activateIndex(i - 1); }} /> @@ -283,7 +256,7 @@ export function InstantiationCellEditor(props: { class="model-specialization-add" onMouseDown={(evt) => { appendSpecialization(); - props.actions.hasFocused(); + props.focus.setFocused(true); evt.preventDefault(); }} > @@ -308,21 +281,13 @@ function SpecializationEditor( instantiatedModel: DblModel | null; /** Called when the displayed text of the id input changes. */ onIdTextChange?: (text: string) => void; - } & Pick< - TextInputOptions, - "isActive" | "hasFocused" | "createBelow" | "deleteBackward" | "exitDown" | "exitUp" - >, + focus: FocusHandle; + } & Pick, ) { const [inputProps, props] = splitProps(allProps, ["createBelow", "exitDown", "exitUp"]); - const [activeInput, setActiveInput] = createSignal("id"); - - // Reset to default on deactivation so re-entry lands on the id input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("id"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted row. + const focus = useChildFocus(props.focus, { default: "id" }); const obType = () => { const id = props.specialization.id; @@ -348,14 +313,10 @@ function SpecializationEditor( completions={props.instantiatedModel?.obGenerators()} idToLabel={(id) => props.instantiatedModel?.obGeneratorLabel(id)} labelToId={(label) => props.instantiatedModel?.obGeneratorWithLabel(label)} - isActive={props.isActive && activeInput() === "id"} - hasFocused={() => { - setActiveInput("id"); - props.hasFocused?.(); - }} + focus={focus.childFocus("id")} deleteBackward={props.deleteBackward} - exitForward={() => setActiveInput("ob")} - exitRight={() => setActiveInput("ob")} + exitForward={() => focus.setActiveChild("ob")} + exitRight={() => focus.setActiveChild("ob")} {...inputProps} /> @@ -370,14 +331,10 @@ function SpecializationEditor( }); }} obType={obType()} - isActive={props.isActive && activeInput() === "ob"} - hasFocused={() => { - setActiveInput("ob"); - props.hasFocused?.(); - }} - deleteBackward={() => setActiveInput("id")} - exitBackward={() => setActiveInput("id")} - exitLeft={() => setActiveInput("id")} + focus={focus.childFocus("ob")} + deleteBackward={() => focus.setActiveChild("id")} + exitBackward={() => focus.setActiveChild("id")} + exitLeft={() => focus.setActiveChild("id")} {...inputProps} /> )} diff --git a/packages/frontend/src/model/model_editor.tsx b/packages/frontend/src/model/model_editor.tsx index f5d4250a9..8236b2e21 100644 --- a/packages/frontend/src/model/model_editor.tsx +++ b/packages/frontend/src/model/model_editor.tsx @@ -4,6 +4,7 @@ import invariant from "tiny-invariant"; import { Model, Nb } from "catcolab-document-methods"; import type { InstantiatedModel, ModelJudgment, MorDecl, ObDecl } from "catcolab-document-types"; +import { type FocusHandle } from "catcolab-ui-components"; import { type CellConstructor, type FormalCellEditorProps, NotebookEditor } from "../notebook"; import { TheoryLibraryContext, type ModelTypeMeta, type Theory } from "../theory"; import { LiveModelContext } from "./context"; @@ -12,7 +13,7 @@ import { InstantiationCellEditor } from "./instantiation_cell_editor"; /** Notebook editor for a model of a double theory. */ -export function ModelNotebookEditor(props: { liveModel: LiveModelDoc }) { +export function ModelNotebookEditor(props: { liveModel: LiveModelDoc; focus: FocusHandle }) { const liveDoc = () => props.liveModel.liveDoc; const cellConstructors = () => { @@ -34,6 +35,7 @@ export function ModelNotebookEditor(props: { liveModel: LiveModelDoc }) { cellConstructors={cellConstructors()} cellLabel={judgmentLabel} duplicateCell={Model.duplicateModelJudgment} + focus={props.focus} /> ); @@ -65,7 +67,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyObject={(f: (decl: ObDecl) => void) => props.changeContent((content) => f(content as ObDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -85,7 +87,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyMorphism={(f: (decl: MorDecl) => void) => props.changeContent((content) => f(content as MorDecl)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} theory={theory()} /> @@ -98,7 +100,7 @@ export function ModelCellEditor(props: FormalCellEditorProps) { modifyInstantiation={(f) => props.changeContent((content) => f(content as InstantiatedModel)) } - isActive={props.isActive} + focus={props.focus} actions={props.actions} /> diff --git a/packages/frontend/src/model/morphism_cell_editor.tsx b/packages/frontend/src/model/morphism_cell_editor.tsx index a48d12f21..b7d459044 100644 --- a/packages/frontend/src/model/morphism_cell_editor.tsx +++ b/packages/frontend/src/model/morphism_cell_editor.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, createSignal, useContext } from "solid-js"; +import { createMemo, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { NameInput, useChildFocus } from "catcolab-ui-components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; import { LiveModelContext } from "./context"; import type { MorphismEditorProps } from "./editors"; @@ -16,14 +16,8 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeInput, setActiveInput] = createSignal("name"); - - // Reset to default on deactivation so re-entry lands on the name input. - createEffect(() => { - if (!props.isActive) { - setActiveInput("name"); - } - }); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted cell. + const focus = useChildFocus(props.focus, { default: "name" }); const morTypeMeta = () => props.theory.modelMorTypeMeta(props.morphism.morType); @@ -81,15 +75,11 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { obType={domType()} applyOp={morTypeMeta()?.domain?.apply} isInvalid={errors().some((err) => err.tag === "Dom" || err.tag === "DomType")} - isActive={props.isActive && activeInput() === "dom"} - deleteForward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("name")} - exitForward={() => setActiveInput("cod")} - exitRight={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("dom"); - props.actions.hasFocused?.(); - }} + focus={focus.childFocus("dom")} + deleteForward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("name")} + exitForward={() => focus.setActiveChild("cod")} + exitRight={() => focus.setActiveChild("name")} />
    @@ -102,19 +92,15 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { mor.name = name; }); }} - isActive={props.isActive && activeInput() === "name"} + focus={focus.childFocus("name")} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} - exitForward={() => setActiveInput("dom")} + exitForward={() => focus.setActiveChild("dom")} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - exitLeft={() => setActiveInput("dom")} - exitRight={() => setActiveInput("cod")} - hasFocused={() => { - setActiveInput("name"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("dom")} + exitRight={() => focus.setActiveChild("cod")} />
    @@ -133,15 +119,11 @@ export default function MorphismCellEditor(props: MorphismEditorProps) { obType={codType()} applyOp={morTypeMeta()?.codomain?.apply} isInvalid={errors().some((err) => err.tag === "Cod" || err.tag === "CodType")} - isActive={props.isActive && activeInput() === "cod"} - deleteBackward={() => setActiveInput("name")} - exitBackward={() => setActiveInput("dom")} + focus={focus.childFocus("cod")} + deleteBackward={() => focus.setActiveChild("name")} + exitBackward={() => focus.setActiveChild("dom")} exitForward={props.actions.activateBelow} - exitLeft={() => setActiveInput("name")} - hasFocused={() => { - setActiveInput("cod"); - props.actions.hasFocused?.(); - }} + exitLeft={() => focus.setActiveChild("name")} />
    diff --git a/packages/frontend/src/model/object_cell_editor.tsx b/packages/frontend/src/model/object_cell_editor.tsx index 46c5e1520..c1e2e92d0 100644 --- a/packages/frontend/src/model/object_cell_editor.tsx +++ b/packages/frontend/src/model/object_cell_editor.tsx @@ -23,14 +23,13 @@ export default function ObjectCellEditor(props: ObjectEditorProps) { ob.name = name; }); }} - isActive={props.isActive} + focus={props.focus} deleteBackward={props.actions.deleteBackward} deleteForward={props.actions.deleteForward} exitBackward={props.actions.activateAbove} exitForward={props.actions.activateBelow} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - hasFocused={props.actions.hasFocused} /> ); diff --git a/packages/frontend/src/model/object_list_editor.tsx b/packages/frontend/src/model/object_list_editor.tsx index 73975ab4f..a074aeb43 100644 --- a/packages/frontend/src/model/object_list_editor.tsx +++ b/packages/frontend/src/model/object_list_editor.tsx @@ -1,7 +1,6 @@ import { batch, createEffect, - createSignal, Index, type JSX, mergeProps, @@ -11,7 +10,7 @@ import { } from "solid-js"; import invariant from "tiny-invariant"; -import type { TextInputOptions } from "catcolab-ui-components"; +import { type FocusHandle, type TextInputOptions, useChildFocus } from "catcolab-ui-components"; import type { Ob, QualifiedName } from "catlog-wasm"; import { ObIdInput } from "../components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; @@ -44,7 +43,17 @@ export function ObListEditor(originalProps: ObListEditorProps) { const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); - const [activeIndex, setActiveIndex] = createSignal(0); + const parentFocus: FocusHandle = { + hasFocus: () => props.focus?.hasFocus() ?? !!props.isActive, + setFocused: (focused) => { + if (props.focus) { + props.focus.setFocused(focused); + } else if (focused) { + props.hasFocused?.(); + } + }, + }; + const focus = useChildFocus(parentFocus, { default: 0 }); // Track which indices have non-empty text (including incomplete input). const inputTexts = new Map(); @@ -73,7 +82,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { updateObList((objects) => { objects.splice(i, 0, null); }); - setActiveIndex(i); + focus.setActiveChild(i); }); }; @@ -89,7 +98,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { // Insert into new object into empty list when focus is gained. createEffect(() => { - if (props.isActive && untrack(obList).length === 0) { + if (parentFocus.hasFocus() && untrack(obList).length === 0) { insertNewOb(0); } }); @@ -104,7 +113,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { // Clean up when the component becomes inactive. createEffect(() => { - if (!props.isActive) { + if (!parentFocus.hasFocus()) { untrack(() => deactivate()); } }); @@ -115,7 +124,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { onMouseDown={(evt) => { if (obList().length === 0) { insertNewOb(0); - props.hasFocused?.(); + parentFocus.setFocused(true); evt.preventDefault(); } }} @@ -139,7 +148,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { liveModel().elaboratedModel()?.obGeneratorWithLabel(label) } completions={completions()} - isActive={props.isActive && activeIndex() === i} + focus={focus.childFocus(i)} deleteBackward={() => batch(() => { updateObList((objects) => { @@ -148,7 +157,7 @@ export function ObListEditor(originalProps: ObListEditorProps) { if (i === 0) { props.deleteBackward?.(); } else { - setActiveIndex(i - 1); + focus.setActiveChild(i - 1); } }) } @@ -168,14 +177,14 @@ export function ObListEditor(originalProps: ObListEditorProps) { if (i === 0) { props.exitLeft?.(); } else { - setActiveIndex(i - 1); + focus.setActiveChild(i - 1); } }} exitRight={() => { if (i === obList().length - 1) { props.exitRight?.(); } else { - setActiveIndex(i + 1); + focus.setActiveChild(i + 1); } }} interceptKeyDown={(evt) => { @@ -184,16 +193,12 @@ export function ObListEditor(originalProps: ObListEditorProps) { return true; } else if (evt.key === "Home" && !evt.shiftKey) { // TODO: Should move to beginning of input. - setActiveIndex(0); + focus.setActiveChild(0); } else if (evt.key === "End" && !evt.shiftKey) { - setActiveIndex(obList().length - 1); + focus.setActiveChild(obList().length - 1); } return false; }} - hasFocused={() => { - setActiveIndex(i); - props.hasFocused?.(); - }} /> )} diff --git a/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx index d777367b0..9731338aa 100644 --- a/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx +++ b/packages/frontend/src/model/string_diagram_morphism_cell_editor.tsx @@ -1,7 +1,7 @@ import { Index, createEffect, createMemo, createSignal, untrack, useContext } from "solid-js"; import invariant from "tiny-invariant"; -import { NameInput } from "catcolab-ui-components"; +import { type FocusHandle, NameInput } from "catcolab-ui-components"; import type { Ob, ObOp, ObType, QualifiedName } from "catlog-wasm"; import { ObIdInput } from "../components"; import { removeProxyAndCopy } from "../util/remove_proxy_and_copy"; @@ -34,7 +34,7 @@ function WireColumn(props: { exitFirstBackward: (() => void) | undefined; /** Called when tabbing forward from the last wire. */ exitLastForward: (() => void) | undefined; - hasFocused: (() => void) | undefined; + setFocused: () => void; }) { const liveModel = useContext(LiveModelContext); invariant(liveModel, "Live model should be provided as context"); @@ -90,7 +90,7 @@ function WireColumn(props: { }} hasFocused={() => { props.activateWire(i); - props.hasFocused?.(); + props.setFocused(); }} /> ); @@ -110,7 +110,7 @@ function WireColumn(props: { class={`${styles.wire} ${styles.addWire}`} onMouseDown={(evt) => { props.insertWire(props.obs.length); - props.hasFocused?.(); + props.setFocused(); evt.preventDefault(); }} > @@ -136,7 +136,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro // Reset to default on deactivation so re-entry lands on the name input. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { setActive({ zone: "name" }); } }); @@ -241,13 +241,23 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro // Clean up when the cell becomes inactive. createEffect(() => { - if (!props.isActive) { + if (!props.focus.hasFocus()) { untrack(() => deactivate()); } }); const completions = () => liveModel().elaboratedModel()?.obGeneratorsWithType(elementObType()); + const nameFocus: FocusHandle = { + hasFocus: () => props.focus.hasFocus() && active()?.zone === "name", + setFocused: (focused) => { + if (focused) { + setActive({ zone: "name" }); + props.focus.setFocused(true); + } + }, + }; + const errors = () => { const validated = liveModel().validatedModel(); if (validated?.tag !== "Invalid") { @@ -265,7 +275,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro completions={completions()} isActive={(i) => { const a = active(); - return props.isActive && a?.zone === "dom" && a.index === i; + return props.focus.hasFocus() && a?.zone === "dom" && a.index === i; }} onTextChange={(i, text) => domInputTexts.set(i, text)} insertWire={insertDom} @@ -285,7 +295,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro insertCod(0); } }} - hasFocused={props.actions.hasFocused} + setFocused={() => props.focus.setFocused(true)} />
    { - setActive({ zone: "name" }); - props.actions.hasFocused?.(); - }} />
    { const a = active(); - return props.isActive && a?.zone === "cod" && a.index === i; + return props.focus.hasFocus() && a?.zone === "cod" && a.index === i; }} onTextChange={(i, text) => codInputTexts.set(i, text)} insertWire={insertCod} @@ -356,7 +362,7 @@ export default function StringDiagramMorphismCellEditor(props: MorphismEditorPro } }} exitLastForward={props.actions.activateBelow} - hasFocused={props.actions.hasFocused} + setFocused={() => props.focus.setFocused(true)} /> ); diff --git a/packages/frontend/src/notebook/notebook_cell.tsx b/packages/frontend/src/notebook/notebook_cell.tsx index 07b6cf7db..f6794ba07 100644 --- a/packages/frontend/src/notebook/notebook_cell.tsx +++ b/packages/frontend/src/notebook/notebook_cell.tsx @@ -17,7 +17,7 @@ import type { EditorView } from "prosemirror-view"; import { createEffect, createSignal, type JSX, onCleanup, Show } from "solid-js"; import type { Uuid } from "catcolab-document-types"; -import { type Completion, Completions, IconButton } from "catcolab-ui-components"; +import { type Completion, Completions, type FocusHandle, IconButton } from "catcolab-ui-components"; import { RichTextEditor } from "../components"; import { CellTypePopover } from "./notebook_editor"; @@ -25,11 +25,8 @@ import "./notebook_cell.css"; /** Props available to all notebook cell editors. */ export type CellEditorProps = { - /** Is the cell requested to be active? - - When this prop changes to `true`, the cell is authorizeed to grab the focus. - */ - isActive: boolean; + /** Focus state for this cell. */ + focus: FocusHandle; /** Actions invokable within the cell. */ actions: CellActions; @@ -61,9 +58,6 @@ export type CellActions = { /** Move this cell down, if possible. */ moveDown: () => void; - - /** The cell has received focus. */ - hasFocused: () => void; }; const cellDragDataKey = Symbol("notebook-cell"); @@ -99,6 +93,7 @@ the cell is rendered by its children. export function NotebookCell(props: { cellId: Uuid; index: number; + focus: FocusHandle; actions: CellActions; children: JSX.Element; tag?: string; @@ -235,6 +230,7 @@ export function NotebookCell(props: {
    { const view = editorView(); - if (props.isActive && view) { + if (props.focus.hasFocus() && view) { view.focus(); } }); @@ -315,7 +311,7 @@ export function RichTextCellEditor( deleteForward={props.actions.deleteForward} exitUp={props.actions.activateAbove} exitDown={props.actions.activateBelow} - onFocus={props.actions.hasFocused} + onFocus={() => props.focus.setFocused(true)} /> ); } diff --git a/packages/frontend/src/notebook/notebook_editor.tsx b/packages/frontend/src/notebook/notebook_editor.tsx index 6187fbd67..8dc6015b7 100644 --- a/packages/frontend/src/notebook/notebook_editor.tsx +++ b/packages/frontend/src/notebook/notebook_editor.tsx @@ -23,10 +23,12 @@ import { type Completion, Completions, type CompletionsRef, + type FocusHandle, IconButton, type KbdKey, keyEventHasModifier, type ModifierKey, + useChildFocus, } from "catcolab-ui-components"; import { materializeFromAutomerge } from "../util/materialize_from_automerge"; import { @@ -86,17 +88,16 @@ export function NotebookEditor(props: { formalCellEditor: Component>; cellConstructors?: CellConstructor[]; cellLabel?: (content: T) => string | undefined; + focus: FocusHandle; /** Called to duplicate an existing cell. If omitted, a deep copy is performed. */ duplicateCell?: (content: T) => T; - - // FIXME: Remove this option once we fix focus management. - noShortcuts?: boolean; }) { - const [activeCell, setActiveCell] = createSignal(null); + // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted notebook. + const cellFocus = useChildFocus(props.focus); const [currentDropTarget, setCurrentDropTarget] = createSignal(null); // Which create-cell popover (if any) the editor has requested to open. @@ -113,13 +114,13 @@ export function NotebookEditor(props: { description, shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { - const [i, n] = [activeCell(), props.notebook.cellOrder.length]; + const [i, n] = [cellFocus.activeChild(), props.notebook.cellOrder.length]; const cellIndex = i != null ? Math.min(i + 1, n) : n; props.changeNotebook((nb) => { Nb.insertCellAtIndex(nb, cc.construct(), cellIndex); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => setActiveCell(cellIndex)); + requestAnimationFrame(() => cellFocus.setActiveChild(cellIndex)); }, }; }); @@ -148,7 +149,7 @@ export function NotebookEditor(props: { Nb.insertCellAtIndex(nb, cc.construct(), index); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => setActiveCell(index)); + requestAnimationFrame(() => cellFocus.setActiveChild(index)); }, }; }); @@ -167,14 +168,14 @@ export function NotebookEditor(props: { }); // Defer so the popover fully closes before we focus the new cell. requestAnimationFrame(() => { - setActiveCell(Nb.numCells(props.notebook) - 1); + cellFocus.setActiveChild(Nb.numCells(props.notebook) - 1); }); }, }; }); makeEventListener(window, "keydown", (evt) => { - if (props.noShortcuts) { + if (!props.focus.hasFocus()) { return; } if (keyEventHasModifier(evt, cellShortcutModifier)) { @@ -195,7 +196,7 @@ export function NotebookEditor(props: { // create-cell popover. The popover is rendered by that anchor, so // it is positioned automatically and doesn't require any DOM // queries here. - const cellIndex = activeCell(); + const cellIndex = cellFocus.activeChild(); setCreatePopoverTarget(cellIndex != null ? cellIndex : "append"); evt.preventDefault(); // Stop the same event from reaching `CellTypePopover`'s window @@ -255,21 +256,12 @@ export function NotebookEditor(props: { }); return ( -
    { - const container = evt.currentTarget; - setTimeout(() => { - if (!container.contains(document.activeElement)) { - setActiveCell(null); - } - }, 0); - }} - > +
    setCreatePopoverTarget(open ? "append" : null)} > @@ -281,17 +273,17 @@ export function NotebookEditor(props: {
      {(cellId, i) => { - const isActive = () => activeCell() === i(); + const focus = () => cellFocus.childFocus(i()); const cellActions: CellActions = { activateAbove() { if (i() > 0) { - setActiveCell(i() - 1); + cellFocus.setActiveChild(i() - 1); } }, activateBelow() { if (i() < Nb.numCells(props.notebook) - 1) { - setActiveCell(i() + 1); + cellFocus.setActiveChild(i() + 1); } }, deleteBackward() { @@ -299,14 +291,14 @@ export function NotebookEditor(props: { props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - setActiveCell(index - 1); + cellFocus.setActiveChild(index - 1); }, deleteForward() { const index = i(); props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - setActiveCell(index); + cellFocus.setActiveChild(index); }, moveUp() { // oxlint-disable-next-line solid/reactivity -- event handler @@ -320,9 +312,6 @@ export function NotebookEditor(props: { Nb.moveCellDown(nb, i()); }); }, - hasFocused() { - setActiveCell(i()); - }, }; const cell = props.notebook.cellContents[cellId]; @@ -345,7 +334,7 @@ export function NotebookEditor(props: { props.changeNotebook((nb) => { Nb.insertCellAtIndex(nb, newCell, index + 1); }); - setActiveCell(index + 1); + cellFocus.setActiveChild(index + 1); }; } @@ -354,6 +343,7 @@ export function NotebookEditor(props: { (props: { cellId={cell.id} handle={props.handle} path={[...props.path, "cellContents", cell.id]} - isActive={isActive()} + focus={focus()} actions={cellActions} /> @@ -391,7 +381,7 @@ export function NotebookEditor(props: { ), ) } - isActive={isActive()} + focus={focus()} actions={cellActions} /> )} @@ -407,6 +397,7 @@ export function NotebookEditor(props: {
      setCreatePopoverTarget(open ? "append" : null)} @@ -428,6 +419,7 @@ Up/Down to move, Enter to select, Escape to close). */ export function CellTypePopover(props: { completions: Completion[]; + focus?: FocusHandle; tooltip?: string; /** Whether the button is visible. Defaults to `true`. The button always remains visible while the popover is open. */ @@ -454,6 +446,9 @@ export function CellTypePopover(props: { if (!isOpen()) { return; } + if (props.focus && !props.focus.hasFocus()) { + return; + } const ref = completionsRef(); if (evt.key === "ArrowDown") { ref?.nextPresumptive(); diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 441734f45..d266a4d0f 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -17,7 +17,15 @@ import { } from "solid-js"; import invariant from "tiny-invariant"; -import { Button, IconButton, ResizableHandle, WarningBanner } from "catcolab-ui-components"; +import { + Button, + type FocusHandle, + IconButton, + ResizableHandle, + rootFocus, + useChildFocus, + WarningBanner, +} from "catcolab-ui-components"; import { getLiveAnalysis, type LiveAnalysisDoc } from "../analysis"; import { AnalysisNotebookEditor } from "../analysis/analysis_editor"; import { AnalysisInfo } from "../analysis/analysis_info"; @@ -63,6 +71,7 @@ export default function DocumentPage() { const params = useParams(); const navigate = useNavigate(); const isSidePanelOpen = () => !!params.subkind && !!params.subref; + const paneFocus = useChildFocus<"primary" | "secondary">(rootFocus, { default: "primary" }); // Redirect if primary and secondary refs match createEffect(() => { @@ -101,6 +110,7 @@ export default function DocumentPage() { ); const closeSidePanel = () => { + paneFocus.setActiveChild("primary"); navigate(`/${params.kind}/${params.ref}`); }; @@ -120,6 +130,7 @@ export default function DocumentPage() { // expand the second panel context?.expand(1); } else { + paneFocus.setActiveChild("primary"); // collapse the second panel context?.collapse(1); // Set the first panel to be the full size @@ -194,6 +205,8 @@ export default function DocumentPage() { setResizableContext={setResizableContext} primaryHistoryOpen={primaryHistoryOpen()} secondaryHistoryOpen={secondaryHistoryOpen()} + primaryFocus={paneFocus.childFocus("primary")} + secondaryFocus={paneFocus.childFocus("secondary")} /> )} @@ -313,6 +326,8 @@ function ResizablePanels(props: { setResizableContext: (context: ContextValue) => void; primaryHistoryOpen: boolean; secondaryHistoryOpen: boolean; + primaryFocus: FocusHandle; + secondaryFocus: FocusHandle; }) { return ( @@ -329,6 +344,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.primaryHistoryOpen} + focus={props.primaryFocus} /> @@ -347,6 +363,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.secondaryHistoryOpen} + focus={props.secondaryFocus} /> )} @@ -365,6 +382,7 @@ export function DocumentPane(props: { refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; historySidebarOpen: boolean; + focus: FocusHandle; }) { const api = useApi(); const [isDeleted, setIsDeleted] = createSignal(false); @@ -404,7 +422,7 @@ export function DocumentPane(props: { // oxlint-disable solid/reactivity -- Context.Provider value getter is reactive return ( props.docRef.refId}> -
      +
      props.focus.setFocused(true)}>
      - {(liveModel) => } + {(liveModel) => ( + + )} {(liveDiagram) => ( - + )} {(liveAnalysis) => ( - + )} diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index de93d87b7..dc382628f 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -22,6 +22,7 @@ export * from "./relative_time"; export * from "./resizable"; export * from "./spinner"; export * from "./text_input"; +export * from "./util/focus"; export * from "./util/keyboard"; export * from "./virtual_list"; export * from "./warning_banner"; diff --git a/packages/ui-components/src/text_input.tsx b/packages/ui-components/src/text_input.tsx index 0d70d21cf..73533c845 100644 --- a/packages/ui-components/src/text_input.tsx +++ b/packages/ui-components/src/text_input.tsx @@ -5,6 +5,7 @@ import { type ComponentProps, createEffect, createSignal, type JSX, splitProps } void focus; import { type Completion, Completions, type CompletionsRef } from "./completions"; +import type { FocusHandle } from "./util/focus"; import { assertTypelevel } from "./util/types"; /** Props for `TextInput` component. */ @@ -16,6 +17,9 @@ type TextInputProps = Omit, "onKeyDown"> & /** Optional props available to a `TextInput` component. */ export type TextInputOptions = TextInputActions & { + /** Focus state for this input. */ + focus?: FocusHandle; + /** Whether the input is active: allowed to the grab the focus. */ isActive?: boolean; @@ -103,6 +107,7 @@ type TextInputActions = { // XXX: Need the list of options as a *value* to split props. const TEXT_INPUT_OPTIONS = [ + "focus", "isActive", "hasFocused", "hasBlurred", @@ -143,7 +148,7 @@ export function TextInput(allProps: TextInputProps) { let ref!: HTMLInputElement; createEffect(() => { - if (options.isActive && document.activeElement !== ref) { + if ((options.focus?.hasFocus() ?? options.isActive) && document.activeElement !== ref) { ref.focus(); // Move cursor to end of input. ref.selectionStart = ref.selectionEnd = ref.value.length; @@ -224,6 +229,7 @@ export function TextInput(allProps: TextInputProps) { value={props.text} use:focus={(isFocused) => { if (isFocused) { + options.focus?.setFocused(true); options.hasFocused?.(); if ( options.completions != null && diff --git a/packages/ui-components/src/util/focus.ts b/packages/ui-components/src/util/focus.ts new file mode 100644 index 000000000..556a5c6c3 --- /dev/null +++ b/packages/ui-components/src/util/focus.ts @@ -0,0 +1,45 @@ +import { createEffect, createSignal, type Accessor } from "solid-js"; + +/** Focus state passed down a component tree. */ +export type FocusHandle = { + hasFocus: Accessor; + setFocused: (focused: boolean) => void; +}; + +/** Root focus handle for a tree that should always remember its last focus. */ +export const rootFocus: FocusHandle = { + hasFocus: () => true, + setFocused: () => {}, +}; + +/** Track which immediate child of a focused parent has focus. */ +export function useChildFocus( + parent: FocusHandle, + options?: { default?: K }, +): { + activeChild: Accessor; + setActiveChild: (child: K | null) => void; + childFocus: (child: K) => FocusHandle; +} { + const [activeChild, setActiveChild] = createSignal(options?.default ?? null); + + createEffect(() => { + if (!parent.hasFocus()) { + setActiveChild(() => options?.default ?? null); + } + }); + + const childFocus = (child: K): FocusHandle => ({ + hasFocus: () => parent.hasFocus() && activeChild() === child, + setFocused: (focused) => { + if (focused) { + setActiveChild(() => child); + parent.setFocused(true); + } else if (activeChild() === child) { + setActiveChild(() => options?.default ?? null); + } + }, + }); + + return { activeChild, setActiveChild, childFocus }; +} From d23d98f6554a35245d2d3d61f572e749d12a8936 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 15:59:28 +0100 Subject: [PATCH 02/10] ENH: Keyboard shortcuts for undo/redo --- packages/frontend/src/page/document_page.tsx | 26 ++++++++++++++++++++ packages/ui-components/src/util/keyboard.ts | 17 +++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index d266a4d0f..1989ab6af 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -1,4 +1,5 @@ import Resizable, { type ContextValue } from "@corvu/resizable"; +import { makeEventListener } from "@solid-primitives/event-listener"; import { Title } from "@solidjs/meta"; import { useNavigate, useParams } from "@solidjs/router"; import ChevronsRight from "lucide-solid/icons/chevrons-right"; @@ -21,6 +22,8 @@ import { Button, type FocusHandle, IconButton, + keyEventHasModifier, + primaryModifier, ResizableHandle, rootFocus, useChildFocus, @@ -419,6 +422,29 @@ export function DocumentPane(props: { const history = useSnapshotHistory(() => props.docRef.refId); + makeEventListener(window, "keydown", (evt) => { + if (!props.focus.hasFocus()) { + return; + } + const key = evt.key.toUpperCase(); + const hasPrimary = keyEventHasModifier(evt, primaryModifier); + if (!hasPrimary || evt.altKey) { + return; + } + + if (key === "Z" && !evt.shiftKey && history.canUndo()) { + history.onUndo(); + return evt.preventDefault(); + } + if ( + ((key === "Z" && evt.shiftKey) || (key === "Y" && !evt.shiftKey)) && + history.canRedo() + ) { + history.onRedo(); + return evt.preventDefault(); + } + }); + // oxlint-disable solid/reactivity -- Context.Provider value getter is reactive return ( props.docRef.refId}> diff --git a/packages/ui-components/src/util/keyboard.ts b/packages/ui-components/src/util/keyboard.ts index 0f44b3807..f1bcdbceb 100644 --- a/packages/ui-components/src/util/keyboard.ts +++ b/packages/ui-components/src/util/keyboard.ts @@ -9,6 +9,23 @@ The types `ModifierKey` and `KbdKey` are borrowed from export type ModifierKey = "Alt" | "Control" | "Meta" | "Shift"; export type KbdKey = ModifierKey | (string & {}); +/** Platform-appropriate primary modifier key for editor shortcuts. + +Uses Meta (Cmd) on Mac and Control elsewhere, matching native app convention. + */ +export const primaryModifier: ModifierKey = navigator.userAgent.includes("Mac") + ? "Meta" + : "Control"; + +/** Platform-appropriate secondary modifier key for editor shortcuts. + +Uses Control on Mac, where Alt/Option remaps keys, and Alt elsewhere, where +Control tends to already be bound. + */ +export const secondaryModifier: ModifierKey = navigator.userAgent.includes("Mac") + ? "Control" + : "Alt"; + /** Returns whether the modifier key is active in the keyboard event. */ export function keyEventHasModifier(evt: KeyboardEvent, key: ModifierKey): boolean { switch (key) { From 1906df613b30f06a2622d6079217a3885e177df4 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 16:12:26 +0100 Subject: [PATCH 03/10] ENH: Indicate focus in sidebar --- packages/frontend/src/page/document_page.tsx | 2 ++ .../src/page/document_page_sidebar.tsx | 26 +++++++++++++++++-- packages/frontend/src/page/sidebar_layout.css | 6 ++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 1989ab6af..6f418055b 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -192,6 +192,8 @@ export default function DocumentPage() { } : undefined; })()} + primaryFocus={paneFocus.childFocus("primary")} + secondaryFocus={paneFocus.childFocus("secondary")} refetchPrimaryDoc={refetchPrimaryDoc} refetchSecondaryDoc={refetchSecondaryDoc} /> diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx index 29c34f6b7..b9726c248 100644 --- a/packages/frontend/src/page/document_page_sidebar.tsx +++ b/packages/frontend/src/page/document_page_sidebar.tsx @@ -2,7 +2,7 @@ import { useNavigate } from "@solidjs/router"; import { createMemo, createResource, For, Show, useContext } from "solid-js"; import { stringify as uuidStringify } from "uuid"; -import { DocumentTypeIcon } from "catcolab-ui-components"; +import { DocumentTypeIcon, type FocusHandle } from "catcolab-ui-components"; import type { Document, Link } from "catlog-wasm"; import { type Api, type LiveDocWithRef, useApi } from "../api"; import { TheoryLibraryContext } from "../theory"; @@ -12,6 +12,8 @@ import { DocumentMenu } from "./document_menu"; export function DocumentSidebar(props: { primaryDoc?: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryFocus: FocusHandle; + secondaryFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -21,6 +23,8 @@ export function DocumentSidebar(props: { @@ -59,6 +63,8 @@ async function getDocParent(doc: Document, api: Api): Promise void; refetchSecondaryDoc: () => void; }) { @@ -78,6 +84,8 @@ function RelatedDocuments(props: { indent={1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryFocus={props.primaryFocus} + secondaryFocus={props.secondaryFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -92,6 +100,8 @@ function DocumentsTreeNode(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryFocus: FocusHandle; + secondaryFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -159,6 +169,8 @@ function DocumentsTreeNode(props: { indent={props.indent} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryFocus={props.primaryFocus} + secondaryFocus={props.secondaryFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -169,6 +181,8 @@ function DocumentsTreeNode(props: { indent={props.indent + 1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} + primaryFocus={props.primaryFocus} + secondaryFocus={props.secondaryFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -183,6 +197,8 @@ function DocumentsTreeLeaf(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; + primaryFocus: FocusHandle; + secondaryFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -193,6 +209,11 @@ function DocumentsTreeLeaf(props: { const clickedRefId = createMemo(() => props.doc.docRef.refId); const primaryRefId = createMemo(() => props.primaryDoc.docRef.refId); const secondaryRefId = createMemo(() => props.secondaryDoc?.docRef.refId); + const isPrimary = () => clickedRefId() === primaryRefId(); + const isSecondary = () => clickedRefId() === secondaryRefId(); + const isFocused = () => + (isPrimary() && props.primaryFocus.hasFocus()) || + (isSecondary() && props.secondaryFocus.hasFocus()); const iconLetters = createMemo(() => { const doc = props.doc.liveDoc.doc; @@ -229,7 +250,8 @@ function DocumentsTreeLeaf(props: { onClick={handleClick} class="related-document" classList={{ - active: props.doc.docRef.refId === props.primaryDoc.docRef.refId, + active: isPrimary() || isSecondary(), + focused: isFocused(), }} style={{ "padding-left": `${props.indent * 16}px` }} > diff --git a/packages/frontend/src/page/sidebar_layout.css b/packages/frontend/src/page/sidebar_layout.css index a97e7344f..2cd8eeb57 100644 --- a/packages/frontend/src/page/sidebar_layout.css +++ b/packages/frontend/src/page/sidebar_layout.css @@ -122,7 +122,7 @@ .related-document { padding: 5px 8px; height: 30px; - border-radius: 6px; + border-left: 3px solid transparent; transition: background 20ms; display: flex; justify-content: space-between; @@ -173,6 +173,10 @@ background: var(--color-hover-bg-dark); } } + + &.focused { + border-left-color: var(--color-alert-question); + } } .resizeable-handle { From d1226ffb8fa14538aff61254ecde38874967f83d Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 16:25:54 +0100 Subject: [PATCH 04/10] ENH: Opening history sidebar grabs focus --- packages/frontend/src/page/document_page.tsx | 27 +++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 6f418055b..8db9ed496 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -175,6 +175,8 @@ export default function DocumentPage() { closeSidePanel={closeSidePanel} togglePrimaryHistorySidebar={togglePrimaryHistorySidebar} toggleSecondaryHistorySidebar={toggleSecondaryHistorySidebar} + primaryFocus={paneFocus.childFocus("primary")} + secondaryFocus={paneFocus.childFocus("secondary")} /> } sidebarContents={ @@ -230,6 +232,8 @@ function SplitPaneToolbar(props: { maximizeSidePanel: () => void; togglePrimaryHistorySidebar: () => void; toggleSecondaryHistorySidebar: () => void; + primaryFocus: FocusHandle; + secondaryFocus: FocusHandle; }) { const secondaryPanelSize = () => props.panelSizes?.[1]; const primaryPanelSize = () => props.panelSizes?.[0]; @@ -239,7 +243,13 @@ function SplitPaneToolbar(props: { - + { + props.primaryFocus.setFocused(true); + props.togglePrimaryHistorySidebar(); + }} + tooltip="Toggle history" + > @@ -250,7 +260,10 @@ function SplitPaneToolbar(props: { style={{ left: `${(primaryPanelSize() ?? 0) * 100}%` }} > { + props.primaryFocus.setFocused(true); + props.togglePrimaryHistorySidebar(); + }} tooltip="Toggle history" > @@ -267,6 +280,7 @@ function SplitPaneToolbar(props: { closeSidePanel={props.closeSidePanel} maximizeSidePanel={props.maximizeSidePanel} toggleHistorySidebar={props.toggleSecondaryHistorySidebar} + focus={props.secondaryFocus} /> )} @@ -281,6 +295,7 @@ function SecondaryToolbar(props: { closeSidePanel: () => void; maximizeSidePanel: () => void; toggleHistorySidebar: () => void; + focus: FocusHandle; }) { return ( <> @@ -306,7 +321,13 @@ function SecondaryToolbar(props: { > {(secondary) => (
      - + { + props.focus.setFocused(true); + props.toggleHistorySidebar(); + }} + tooltip="Toggle history" + > Date: Tue, 19 May 2026 16:29:50 +0100 Subject: [PATCH 05/10] ENH: Set focused on side-bar click on doc --- packages/frontend/src/page/document_page_sidebar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx index b9726c248..d2b238da2 100644 --- a/packages/frontend/src/page/document_page_sidebar.tsx +++ b/packages/frontend/src/page/document_page_sidebar.tsx @@ -232,14 +232,17 @@ function DocumentsTreeLeaf(props: { const handleClick = async () => { // If clicking on primary or secondary doc, navigate to just that doc if (clickedRefId() === primaryRefId() || clickedRefId() === secondaryRefId()) { + props.primaryFocus.setFocused(true); navigate(`/${createLinkPart(props.doc)}`); } else { // Otherwise, open it as a side panel or put on the left if it is a parent doc const clickedDoc = props.doc; const parentOfPrimary = await getDocParent(props.primaryDoc.liveDoc.doc, api); if (parentOfPrimary && clickedDoc.docRef.refId === parentOfPrimary.docRef.refId) { + props.primaryFocus.setFocused(true); navigate(`/${createLinkPart(clickedDoc)}/${createLinkPart(props.primaryDoc)}`); } else { + props.secondaryFocus.setFocused(true); navigate(`/${createLinkPart(props.primaryDoc)}/${createLinkPart(clickedDoc)}`); } } From 4166fae98e51a9647b30ec52b85cea46a639b0cb Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 16:31:58 +0100 Subject: [PATCH 06/10] REFACTOR: Include "pane" in the focus names --- packages/frontend/src/page/document_page.tsx | 34 +++++++-------- .../src/page/document_page_sidebar.tsx | 42 +++++++++---------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/frontend/src/page/document_page.tsx b/packages/frontend/src/page/document_page.tsx index 8db9ed496..5ea18b825 100644 --- a/packages/frontend/src/page/document_page.tsx +++ b/packages/frontend/src/page/document_page.tsx @@ -175,8 +175,8 @@ export default function DocumentPage() { closeSidePanel={closeSidePanel} togglePrimaryHistorySidebar={togglePrimaryHistorySidebar} toggleSecondaryHistorySidebar={toggleSecondaryHistorySidebar} - primaryFocus={paneFocus.childFocus("primary")} - secondaryFocus={paneFocus.childFocus("secondary")} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} /> } sidebarContents={ @@ -194,8 +194,8 @@ export default function DocumentPage() { } : undefined; })()} - primaryFocus={paneFocus.childFocus("primary")} - secondaryFocus={paneFocus.childFocus("secondary")} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} refetchPrimaryDoc={refetchPrimaryDoc} refetchSecondaryDoc={refetchSecondaryDoc} /> @@ -212,8 +212,8 @@ export default function DocumentPage() { setResizableContext={setResizableContext} primaryHistoryOpen={primaryHistoryOpen()} secondaryHistoryOpen={secondaryHistoryOpen()} - primaryFocus={paneFocus.childFocus("primary")} - secondaryFocus={paneFocus.childFocus("secondary")} + primaryPaneFocus={paneFocus.childFocus("primary")} + secondaryPaneFocus={paneFocus.childFocus("secondary")} /> )} @@ -232,8 +232,8 @@ function SplitPaneToolbar(props: { maximizeSidePanel: () => void; togglePrimaryHistorySidebar: () => void; toggleSecondaryHistorySidebar: () => void; - primaryFocus: FocusHandle; - secondaryFocus: FocusHandle; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; }) { const secondaryPanelSize = () => props.panelSizes?.[1]; const primaryPanelSize = () => props.panelSizes?.[0]; @@ -245,7 +245,7 @@ function SplitPaneToolbar(props: { { - props.primaryFocus.setFocused(true); + props.primaryPaneFocus.setFocused(true); props.togglePrimaryHistorySidebar(); }} tooltip="Toggle history" @@ -261,7 +261,7 @@ function SplitPaneToolbar(props: { > { - props.primaryFocus.setFocused(true); + props.primaryPaneFocus.setFocused(true); props.togglePrimaryHistorySidebar(); }} tooltip="Toggle history" @@ -280,7 +280,7 @@ function SplitPaneToolbar(props: { closeSidePanel={props.closeSidePanel} maximizeSidePanel={props.maximizeSidePanel} toggleHistorySidebar={props.toggleSecondaryHistorySidebar} - focus={props.secondaryFocus} + secondaryPaneFocus={props.secondaryPaneFocus} /> )} @@ -295,7 +295,7 @@ function SecondaryToolbar(props: { closeSidePanel: () => void; maximizeSidePanel: () => void; toggleHistorySidebar: () => void; - focus: FocusHandle; + secondaryPaneFocus: FocusHandle; }) { return ( <> @@ -323,7 +323,7 @@ function SecondaryToolbar(props: {
      { - props.focus.setFocused(true); + props.secondaryPaneFocus.setFocused(true); props.toggleHistorySidebar(); }} tooltip="Toggle history" @@ -352,8 +352,8 @@ function ResizablePanels(props: { setResizableContext: (context: ContextValue) => void; primaryHistoryOpen: boolean; secondaryHistoryOpen: boolean; - primaryFocus: FocusHandle; - secondaryFocus: FocusHandle; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; }) { return ( @@ -370,7 +370,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.primaryHistoryOpen} - focus={props.primaryFocus} + focus={props.primaryPaneFocus} /> @@ -389,7 +389,7 @@ function ResizablePanels(props: { refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} historySidebarOpen={props.secondaryHistoryOpen} - focus={props.secondaryFocus} + focus={props.secondaryPaneFocus} /> )} diff --git a/packages/frontend/src/page/document_page_sidebar.tsx b/packages/frontend/src/page/document_page_sidebar.tsx index d2b238da2..dc053db99 100644 --- a/packages/frontend/src/page/document_page_sidebar.tsx +++ b/packages/frontend/src/page/document_page_sidebar.tsx @@ -12,8 +12,8 @@ import { DocumentMenu } from "./document_menu"; export function DocumentSidebar(props: { primaryDoc?: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; - primaryFocus: FocusHandle; - secondaryFocus: FocusHandle; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -23,8 +23,8 @@ export function DocumentSidebar(props: { @@ -63,8 +63,8 @@ async function getDocParent(doc: Document, api: Api): Promise void; refetchSecondaryDoc: () => void; }) { @@ -84,8 +84,8 @@ function RelatedDocuments(props: { indent={1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} - primaryFocus={props.primaryFocus} - secondaryFocus={props.secondaryFocus} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -100,8 +100,8 @@ function DocumentsTreeNode(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; - primaryFocus: FocusHandle; - secondaryFocus: FocusHandle; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -169,8 +169,8 @@ function DocumentsTreeNode(props: { indent={props.indent} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} - primaryFocus={props.primaryFocus} - secondaryFocus={props.secondaryFocus} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -181,8 +181,8 @@ function DocumentsTreeNode(props: { indent={props.indent + 1} primaryDoc={props.primaryDoc} secondaryDoc={props.secondaryDoc} - primaryFocus={props.primaryFocus} - secondaryFocus={props.secondaryFocus} + primaryPaneFocus={props.primaryPaneFocus} + secondaryPaneFocus={props.secondaryPaneFocus} refetchPrimaryDoc={props.refetchPrimaryDoc} refetchSecondaryDoc={props.refetchSecondaryDoc} /> @@ -197,8 +197,8 @@ function DocumentsTreeLeaf(props: { indent: number; primaryDoc: LiveDocWithRef; secondaryDoc?: LiveDocWithRef; - primaryFocus: FocusHandle; - secondaryFocus: FocusHandle; + primaryPaneFocus: FocusHandle; + secondaryPaneFocus: FocusHandle; refetchPrimaryDoc: () => void; refetchSecondaryDoc: () => void; }) { @@ -212,8 +212,8 @@ function DocumentsTreeLeaf(props: { const isPrimary = () => clickedRefId() === primaryRefId(); const isSecondary = () => clickedRefId() === secondaryRefId(); const isFocused = () => - (isPrimary() && props.primaryFocus.hasFocus()) || - (isSecondary() && props.secondaryFocus.hasFocus()); + (isPrimary() && props.primaryPaneFocus.hasFocus()) || + (isSecondary() && props.secondaryPaneFocus.hasFocus()); const iconLetters = createMemo(() => { const doc = props.doc.liveDoc.doc; @@ -232,17 +232,17 @@ function DocumentsTreeLeaf(props: { const handleClick = async () => { // If clicking on primary or secondary doc, navigate to just that doc if (clickedRefId() === primaryRefId() || clickedRefId() === secondaryRefId()) { - props.primaryFocus.setFocused(true); + props.primaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(props.doc)}`); } else { // Otherwise, open it as a side panel or put on the left if it is a parent doc const clickedDoc = props.doc; const parentOfPrimary = await getDocParent(props.primaryDoc.liveDoc.doc, api); if (parentOfPrimary && clickedDoc.docRef.refId === parentOfPrimary.docRef.refId) { - props.primaryFocus.setFocused(true); + props.primaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(clickedDoc)}/${createLinkPart(props.primaryDoc)}`); } else { - props.secondaryFocus.setFocused(true); + props.secondaryPaneFocus.setFocused(true); navigate(`/${createLinkPart(props.primaryDoc)}/${createLinkPart(clickedDoc)}`); } } From 7716a3cb8a2a1ce3e3f7467d4b2174aaa68d0e7d Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 16:44:53 +0100 Subject: [PATCH 07/10] FIX: Keep focus on completion escape key press --- packages/ui-components/src/text_input.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/text_input.tsx b/packages/ui-components/src/text_input.tsx index 73533c845..c3aeaef2b 100644 --- a/packages/ui-components/src/text_input.tsx +++ b/packages/ui-components/src/text_input.tsx @@ -161,7 +161,10 @@ export function TextInput(allProps: TextInputProps) { const onKeyDown: JSX.EventHandler = (evt) => { const remaining = completionsRef()?.remainingCompletions() ?? []; const value = evt.currentTarget.value; - if (options.interceptKeyDown?.(evt)) { + if (evt.key === "Escape" && isCompletionsOpen()) { + setCompletionsOpen(false); + evt.stopPropagation(); + } else if (options.interceptKeyDown?.(evt)) { } else if (options.deleteBackward && evt.key === "Backspace" && !value) { options.deleteBackward(); } else if (options.deleteForward && evt.key === "Delete" && !value) { From f9dd2b62432ca113aeb567a74a2a9ac1889ef37a Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 17:09:17 +0100 Subject: [PATCH 08/10] FIX: Use uuid to track active cell rather than index --- .../frontend/src/notebook/notebook_editor.tsx | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/frontend/src/notebook/notebook_editor.tsx b/packages/frontend/src/notebook/notebook_editor.tsx index 8dc6015b7..b53d281f3 100644 --- a/packages/frontend/src/notebook/notebook_editor.tsx +++ b/packages/frontend/src/notebook/notebook_editor.tsx @@ -18,7 +18,7 @@ import { import invariant from "tiny-invariant"; import { Nb } from "catcolab-document-methods"; -import type { Cell, Notebook } from "catcolab-document-types"; +import type { Cell, Notebook, Uuid } from "catcolab-document-types"; import { type Completion, Completions, @@ -44,10 +44,10 @@ import "./notebook_editor.css"; /** Identifies which create-cell popover, if any, the editor wants open. -Either an index of an existing cell (open the "create below" popover anchored -to that cell) or `"append"` (open the popover for the end-of-notebook button). +Either the id of an existing cell (open the "create below" popover anchored to +that cell) or `"append"` (open the popover for the end-of-notebook button). */ -type CreatePopoverTarget = number | "append" | null; +type CreatePopoverTarget = Uuid | "append" | null; /** Constructor for a cell in a notebook. @@ -97,7 +97,7 @@ export function NotebookEditor(props: { duplicateCell?: (content: T) => T; }) { // oxlint-disable-next-line solid/reactivity -- Focus handles are stable for a mounted notebook. - const cellFocus = useChildFocus(props.focus); + const cellFocus = useChildFocus(props.focus); const [currentDropTarget, setCurrentDropTarget] = createSignal(null); // Which create-cell popover (if any) the editor has requested to open. @@ -114,13 +114,20 @@ export function NotebookEditor(props: { description, shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { - const [i, n] = [cellFocus.activeChild(), props.notebook.cellOrder.length]; - const cellIndex = i != null ? Math.min(i + 1, n) : n; + const activeCellId = cellFocus.activeChild(); + const activeIndex = activeCellId + ? props.notebook.cellOrder.indexOf(activeCellId) + : -1; + const cellIndex = + activeIndex >= 0 + ? Math.min(activeIndex + 1, props.notebook.cellOrder.length) + : props.notebook.cellOrder.length; + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.insertCellAtIndex(nb, cc.construct(), cellIndex); + Nb.insertCellAtIndex(nb, newCell, cellIndex); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => cellFocus.setActiveChild(cellIndex)); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); @@ -145,11 +152,12 @@ export function NotebookEditor(props: { shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { const index = i + 1; + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.insertCellAtIndex(nb, cc.construct(), index); + Nb.insertCellAtIndex(nb, newCell, index); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => cellFocus.setActiveChild(index)); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); @@ -163,13 +171,12 @@ export function NotebookEditor(props: { description, shortcut: shortcut && [cellShortcutModifier, ...shortcut], onComplete: () => { + const newCell = cc.construct(); props.changeNotebook((nb) => { - Nb.appendCell(nb, cc.construct()); + Nb.appendCell(nb, newCell); }); // Defer so the popover fully closes before we focus the new cell. - requestAnimationFrame(() => { - cellFocus.setActiveChild(Nb.numCells(props.notebook) - 1); - }); + requestAnimationFrame(() => cellFocus.setActiveChild(newCell.id)); }, }; }); @@ -196,8 +203,8 @@ export function NotebookEditor(props: { // create-cell popover. The popover is rendered by that anchor, so // it is positioned automatically and doesn't require any DOM // queries here. - const cellIndex = cellFocus.activeChild(); - setCreatePopoverTarget(cellIndex != null ? cellIndex : "append"); + const cellId = cellFocus.activeChild(); + setCreatePopoverTarget(cellId ?? "append"); evt.preventDefault(); // Stop the same event from reaching `CellTypePopover`'s window // keydown listener, which would otherwise see the popover as @@ -273,32 +280,41 @@ export function NotebookEditor(props: {
        {(cellId, i) => { - const focus = () => cellFocus.childFocus(i()); + const focus = () => cellFocus.childFocus(cellId); const cellActions: CellActions = { activateAbove() { if (i() > 0) { - cellFocus.setActiveChild(i() - 1); + cellFocus.setActiveChild( + props.notebook.cellOrder[i() - 1] ?? null, + ); } }, activateBelow() { if (i() < Nb.numCells(props.notebook) - 1) { - cellFocus.setActiveChild(i() + 1); + cellFocus.setActiveChild( + props.notebook.cellOrder[i() + 1] ?? null, + ); } }, deleteBackward() { const index = i(); + const nextActiveId = props.notebook.cellOrder[index - 1] ?? null; props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - cellFocus.setActiveChild(index - 1); + cellFocus.setActiveChild(nextActiveId); }, deleteForward() { const index = i(); + const nextActiveId = + props.notebook.cellOrder[index + 1] ?? + props.notebook.cellOrder[index - 1] ?? + null; props.changeNotebook((nb) => { Nb.deleteCellAtIndex(nb, index); }); - cellFocus.setActiveChild(index); + cellFocus.setActiveChild(nextActiveId); }, moveUp() { // oxlint-disable-next-line solid/reactivity -- event handler @@ -334,7 +350,7 @@ export function NotebookEditor(props: { props.changeNotebook((nb) => { Nb.insertCellAtIndex(nb, newCell, index + 1); }); - cellFocus.setActiveChild(index + 1); + cellFocus.setActiveChild(newCell.id); }; } @@ -351,9 +367,9 @@ export function NotebookEditor(props: { : undefined } createCompletions={createBelowCommands(i())} - popoverOpen={createPopoverTarget() === i()} + popoverOpen={createPopoverTarget() === cellId} setPopoverOpen={(open) => - setCreatePopoverTarget(open ? i() : null) + setCreatePopoverTarget(open ? cellId : null) } currentDropTarget={currentDropTarget()} setCurrentDropTarget={setCurrentDropTarget} From 002a800118a424d9f5154ff3438c82439407d930 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Fri, 15 May 2026 15:27:03 +0100 Subject: [PATCH 09/10] ENH: Add keyboard shortcut indication to undo/redo button tooltip --- .../frontend/src/page/history_sidebar.tsx | 5 +-- packages/ui-components/src/util/keyboard.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/page/history_sidebar.tsx b/packages/frontend/src/page/history_sidebar.tsx index 50b1db463..de635b1da 100644 --- a/packages/frontend/src/page/history_sidebar.tsx +++ b/packages/frontend/src/page/history_sidebar.tsx @@ -1,3 +1,4 @@ +import { formatShortcut, primaryModifier } from "catcolab-ui-components"; import { HistoryNavigator } from "catcolab-ui-components"; import type { SnapshotHistory } from "./use_snapshot_history"; @@ -10,8 +11,8 @@ export function HistorySidebar(props: { history: SnapshotHistory }) { onUndo={props.history.onUndo} onRedo={props.history.onRedo} onSelect={props.history.navigate} - undoTooltip="Undo" - redoTooltip="Redo" + undoTooltip={`Undo (${formatShortcut([primaryModifier, "Z"])})`} + redoTooltip={`Redo (${formatShortcut([primaryModifier, "Shift", "Z"])} or ${formatShortcut([primaryModifier, "Y"])})`} /> ); } diff --git a/packages/ui-components/src/util/keyboard.ts b/packages/ui-components/src/util/keyboard.ts index f1bcdbceb..23762a188 100644 --- a/packages/ui-components/src/util/keyboard.ts +++ b/packages/ui-components/src/util/keyboard.ts @@ -41,3 +41,36 @@ export function keyEventHasModifier(evt: KeyboardEvent, key: ModifierKey): boole throw new Error(`Key is not a modifier: ${key}`); } } + +/** Format a modifier key for display to the user (e.g. "Cmd" on Mac, "Ctrl" elsewhere). */ +function formatModifierKey(key: ModifierKey, isMac: boolean): string { + switch (key) { + case "Meta": + return isMac ? "Cmd" : "Win"; + case "Control": + return isMac ? "⌃" : "Ctrl"; + case "Shift": + return "Shift"; + case "Alt": + return isMac ? "⌥" : "Alt"; + } +} + +/** Format a keyboard shortcut for display to the user. + * + * Takes an array of keys (modifiers followed by the main key) and returns + * a human-readable string like "Cmd+Z" or "Ctrl+Shift+Z". + */ +export function formatShortcut(keys: KbdKey[]): string { + if (keys.length === 0) { + return ""; + } + const isMac = navigator.userAgent.includes("Mac"); + const parts = keys.map((key) => { + if (key === "Meta" || key === "Control" || key === "Alt" || key === "Shift") { + return formatModifierKey(key as ModifierKey, isMac); + } + return key; + }); + return parts.join("+"); +} From 224e55b939168a0e8b496b0dab418d528120b1b7 Mon Sep 17 00:00:00 2001 From: Kaspar Bumke Date: Tue, 19 May 2026 17:31:55 +0100 Subject: [PATCH 10/10] FIX: Update gaios for FocusHandle --- packages/gaios/src/model_tool.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/gaios/src/model_tool.tsx b/packages/gaios/src/model_tool.tsx index cc04d9d8c..4d14fcfe0 100644 --- a/packages/gaios/src/model_tool.tsx +++ b/packages/gaios/src/model_tool.tsx @@ -11,6 +11,7 @@ import { ModelNotebookEditor } from "../../frontend/src/model/model_editor"; import { ModelDocumentHead } from "../../frontend/src/model/model_info"; import { stdTheories } from "../../frontend/src/stdlib"; import { TheoryLibraryContext } from "../../frontend/src/theory"; +import { rootFocus } from "../../ui-components/src/util/focus"; import type { ModelDoc } from "./model_datatype"; import "../../ui-components/src/global.css"; @@ -54,7 +55,10 @@ export function renderModelTool(handle: DocHandle, element: ToolElemen - + )}