From 5aae5cfdc6799a6c299699b1072ecb4e983ffc73 Mon Sep 17 00:00:00 2001
From: Adrian Vogelsgesang
Date: Wed, 7 Oct 2026 04:16:40 +0000
Subject: [PATCH 1/3] Add settings for CodeMirror themes and keybindings
Users can now choose a System, Light, or Dark CodeMirror theme and switch
between Standard, Vim, and Emacs keybindings. These preferences persist and
synchronize across tabs, while documents remain read-only in every keyboard
mode.
Keep application settings, their panel, and persistence in the standalone
app, which is the only consumer of the CodeMirror preferences. The reusable
QueryGraph renderer instead accepts its animation speed explicitly and
provides a generic controls slot for host UI. This avoids imposing application
state and local-storage behavior on library embedders.
The underlying theme setting is intentionally named `theme` so it can
eventually style the full application, although the UI currently labels it
"CodeMirror theme". System mode follows live browser color-scheme changes,
and dark mode uses One Dark. Semantic theme colors keep search controls and
linked ranges legible while allowing future themes to provide their own
palette.
Reconfigure theme and keyboard extensions through CodeMirror compartments
instead of recreating the editor. This preserves selection, scrolling, folds,
search state, and mode-specific keybinding state when preferences change.
Reject document-changing transactions because the third-party modes do not
consistently honor CodeMirror's read-only facet.
Review consideration:
* Do not add a React Flow integration test solely for the direct
`additionalControls` and `animationSpeed` prop forwarding. The standalone
production build verifies their composition, while focused tests cover
animation-duration resolution and the complete settings panel.
---
package.json | 2 +
pnpm-lock.yaml | 62 ++++++++++++
query-graphs/README.md | 2 +
query-graphs/src/ui/QueryGraph.tsx | 27 ++++--
query-graphs/src/ui/animation-timing.ts | 14 ++-
query-graphs/src/ui/useAnimatedGraphLayout.ts | 6 +-
query-graphs/test/animation-timing.test.ts | 7 +-
standalone-app/package.json | 6 +-
standalone-app/src/CodeMirrorDocument.tsx | 95 ++++++++++++++++++-
standalone-app/src/CodeMirrorSearch.ts | 17 +++-
standalone-app/src/QueryGraphsApp.tsx | 13 ++-
standalone-app/src/RangeLinking.ts | 24 +++--
.../src}/SettingsPanel.css | 42 ++++----
.../src}/SettingsPanel.tsx | 38 ++++++--
.../src/ui => standalone-app/src}/settings.ts | 33 +++++--
.../test/codemirror-document.test.ts | 82 +++++++++++++++-
.../test/settings-panel.test.ts | 38 +++++---
.../test/settings.test.ts | 34 +++++--
18 files changed, 445 insertions(+), 97 deletions(-)
rename {query-graphs/src/ui => standalone-app/src}/SettingsPanel.css (82%)
rename {query-graphs/src/ui => standalone-app/src}/SettingsPanel.tsx (64%)
rename {query-graphs/src/ui => standalone-app/src}/settings.ts (69%)
rename {query-graphs => standalone-app}/test/settings-panel.test.ts (83%)
rename {query-graphs => standalone-app}/test/settings.test.ts (79%)
diff --git a/package.json b/package.json
index 1759f2fd..dc574861 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,9 @@
"scripts": {
"lint": "eslint . --fix",
"lint-test": "eslint .",
+ "pretest": "pnpm --filter @tableau/query-graphs build",
"test": "node --import=tsx --test --test-isolation=none \"query-graphs/test/*.test.ts\" \"standalone-app/test/*.test.ts\"",
+ "pretest:update": "pnpm --filter @tableau/query-graphs build",
"test:update": "node --import=tsx --test --test-isolation=none --test-update-snapshots \"query-graphs/test/*.test.ts\" \"standalone-app/test/*.test.ts\""
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1ca70372..db098c48 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -120,9 +120,18 @@ importers:
'@codemirror/state':
specifier: ^6.7.5
version: 6.7.5
+ '@codemirror/theme-one-dark':
+ specifier: ^6.1.3
+ version: 6.1.3
'@codemirror/view':
specifier: ^6.43.12
version: 6.43.12
+ '@replit/codemirror-emacs':
+ specifier: ^6.1.0
+ version: 6.1.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.11.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)
+ '@replit/codemirror-vim':
+ specifier: ^6.4.0
+ version: 6.4.0(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/search@6.7.2)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)
'@tableau/query-graphs':
specifier: workspace:^
version: link:../query-graphs
@@ -192,6 +201,9 @@ importers:
workbox-webpack-plugin:
specifier: ^7.4.1
version: 7.4.1(supports-color@8.1.1)(webpack@5.111.1)
+ zustand:
+ specifier: ^5.0.0
+ version: 5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.7.0(react@19.3.0))
upload-server:
dependencies:
@@ -759,6 +771,9 @@ packages:
'@codemirror/state@6.7.5':
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
+ '@codemirror/theme-one-dark@6.1.3':
+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
+
'@codemirror/view@6.43.12':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
@@ -1442,6 +1457,27 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@replit/codemirror-emacs@6.1.0':
+ resolution: {integrity: sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==}
+ peerDependencies:
+ '@codemirror/autocomplete': ^6.0.2
+ '@codemirror/commands': ^6.0.0
+ '@codemirror/search': ^6.0.0
+ '@codemirror/state': ^6.0.1
+ '@codemirror/view': ^6.3.0
+
+ '@replit/codemirror-vim-core@0.1.0':
+ resolution: {integrity: sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ==}
+
+ '@replit/codemirror-vim@6.4.0':
+ resolution: {integrity: sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A==}
+ peerDependencies:
+ '@codemirror/commands': 6.x.x
+ '@codemirror/language': 6.x.x
+ '@codemirror/search': 6.x.x
+ '@codemirror/state': 6.x.x
+ '@codemirror/view': 6.x.x
+
'@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
engines: {node: '>=14.0.0'}
@@ -5282,6 +5318,13 @@ snapshots:
dependencies:
'@marijn/find-cluster-break': 1.0.4
+ '@codemirror/theme-one-dark@6.1.3':
+ dependencies:
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/highlight': 1.2.3
+
'@codemirror/view@6.43.12':
dependencies:
'@codemirror/state': 6.7.5
@@ -5856,6 +5899,25 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@replit/codemirror-emacs@6.1.0(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.11.1)(@codemirror/search@6.7.2)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/commands': 6.11.1
+ '@codemirror/search': 6.7.2
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+
+ '@replit/codemirror-vim-core@0.1.0': {}
+
+ '@replit/codemirror-vim@6.4.0(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/search@6.7.2)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)':
+ dependencies:
+ '@codemirror/commands': 6.11.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/search': 6.7.2
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@replit/codemirror-vim-core': 0.1.0
+
'@rollup/plugin-babel@6.1.0(@babel/core@7.29.7(supports-color@8.1.1))(rollup@4.63.3)(supports-color@8.1.1)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
diff --git a/query-graphs/README.md b/query-graphs/README.md
index 75cec218..83533aa9 100644
--- a/query-graphs/README.md
+++ b/query-graphs/README.md
@@ -165,6 +165,8 @@ function MyPlanViewer({planText}: {planText: string}) {
}
```
+`animationSpeed` selects the graph-transition duration and defaults to `"medium"`; the browser's reduced-motion preference always disables those transitions. Hosts can append buttons beside the built-in graph controls through `additionalControls`. Use the exported `QueryGraphControlButton` for matching React Flow styling without depending on React Flow directly.
+
Pass `{format: "hyper"}` (or another registered format) as the second argument to bypass automatic format detection.
The component imports its own CSS (`QueryGraph.css`, `QueryNode.css`, `NodeIcon.css`) and react-flow's default stylesheet; with a bundler that honors the package's `sideEffects`, those styles are included automatically when you import the component.
diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx
index 06e0d6e2..707a3f3e 100644
--- a/query-graphs/src/ui/QueryGraph.tsx
+++ b/query-graphs/src/ui/QueryGraph.tsx
@@ -4,7 +4,7 @@ import "@xyflow/react/dist/style.css";
import type {TreeDescription, TreeNode} from "../tree-description";
import type {MouseEvent, ReactNode} from "react";
-import {useId, useMemo} from "react";
+import {useMemo} from "react";
import cc from "classcat";
import {QueryNode} from "./QueryNode";
import type {QueryGraphNode} from "./QueryNode";
@@ -13,14 +13,20 @@ import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRendering
import {AnimateGraphChangeContext, useAnimatedGraphLayout, useAnimateGraphChange} from "./useAnimatedGraphLayout";
import {indexGraph} from "./graph-index";
import type {TreeParents} from "./tree-topology";
-import {SettingsButton, SettingsPanel} from "./SettingsPanel";
+import type {AnimationSpeed} from "./animation-timing";
import "./QueryGraph.css";
-interface QueryGraphProps {
+export interface QueryGraphProps {
treeDescription: TreeDescription;
children: ReactNode | ReactNode[];
+ /** Selects the duration of graph layout transitions. Defaults to medium. */
+ animationSpeed?: AnimationSpeed;
+ /** Additional buttons, normally `QueryGraphControlButton`s, appended to the built-in graph controls. */
+ additionalControls?: ReactNode;
}
+export const QueryGraphControlButton = ControlButton;
+
interface QueryGraphInternalProps extends QueryGraphProps {
nodeIdMapping: ReadonlyMap;
treeParents: TreeParents;
@@ -83,10 +89,16 @@ function ExpandedNodesControl() {
);
}
-function QueryGraphInternal({treeDescription, children, nodeIdMapping, treeParents}: QueryGraphInternalProps) {
- const settingsPopoverId = useId();
+function QueryGraphInternal({
+ treeDescription,
+ children,
+ animationSpeed = "medium",
+ additionalControls,
+ nodeIdMapping,
+ treeParents,
+}: QueryGraphInternalProps) {
const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees);
- const animatedLayout = useAnimatedGraphLayout(treeDescription, nodeIdMapping, treeParents, expandedSubtrees);
+ const animatedLayout = useAnimatedGraphLayout(treeDescription, nodeIdMapping, treeParents, expandedSubtrees, animationSpeed);
// Hide the full tree initially to avoid flickering. We use `opacity` instead
// of `visibility: hidden` because React Flow overrides inherited
// visibility on nodes after measuring them.
@@ -116,9 +128,8 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping, treeParen
-
+ {additionalControls}
-
);
diff --git a/query-graphs/src/ui/animation-timing.ts b/query-graphs/src/ui/animation-timing.ts
index 07471a34..29e3256f 100644
--- a/query-graphs/src/ui/animation-timing.ts
+++ b/query-graphs/src/ui/animation-timing.ts
@@ -1,5 +1,14 @@
import {useSyncExternalStore} from "react";
-import {animationSpeedOptions, settingsStore} from "./settings";
+
+export const animationSpeedOptions = [
+ {value: "off", label: "Off", duration: undefined},
+ {value: "fast", label: "Fast", duration: 100},
+ {value: "medium", label: "Medium", duration: 200},
+ {value: "slow", label: "Slow", duration: 500},
+ {value: "debug", label: "Debug", duration: 5000},
+] as const;
+
+export type AnimationSpeed = (typeof animationSpeedOptions)[number]["value"];
const reducedMotionQuery = "(prefers-reduced-motion: reduce)";
@@ -16,9 +25,8 @@ export function usePrefersReducedMotion(): boolean {
);
}
-export function getGraphAnimationDuration(): number | undefined {
+export function getGraphAnimationDuration(animationSpeed: AnimationSpeed): number | undefined {
if (window.matchMedia(reducedMotionQuery).matches) return undefined;
- const animationSpeed = settingsStore.getState().values.animationSpeed;
return animationSpeedOptions.find(({value}) => value === animationSpeed)?.duration;
}
diff --git a/query-graphs/src/ui/useAnimatedGraphLayout.ts b/query-graphs/src/ui/useAnimatedGraphLayout.ts
index 99140127..4afba2fd 100644
--- a/query-graphs/src/ui/useAnimatedGraphLayout.ts
+++ b/query-graphs/src/ui/useAnimatedGraphLayout.ts
@@ -17,6 +17,7 @@ import type {TreeDescription, TreeNode} from "../tree-description";
import type {QueryGraphNode} from "./QueryNode";
import {layoutTree} from "./tree-layout";
import {getGraphAnimationDuration, graphAnimationProgress} from "./animation-timing";
+import type {AnimationSpeed} from "./animation-timing";
import type {GraphLayout} from "./animated-layout";
import {
closestAnimationAnchor,
@@ -181,6 +182,7 @@ export function useAnimatedGraphLayout(
nodeIds: ReadonlyMap,
treeParents: TreeParents,
expandedSubtrees: Record,
+ animationSpeed: AnimationSpeed,
): GraphLayout & {
onNodesChange: (changes: NodeChange[]) => void;
animateGraphChange: AnimateGraphChange;
@@ -259,11 +261,11 @@ export function useAnimatedGraphLayout(
for (const {nodeId} of resizingNodes) finishNodeResize(nodeResizesRef.current, nodeId);
for (const [nodeId, resize] of resizes) nodeResizesRef.current.set(nodeId, resize);
animationAnchorNodeIdsRef.current = anchorAllVisibleNodes ? new Set() : undefined;
- animationDurationRef.current = getGraphAnimationDuration();
+ animationDurationRef.current = getGraphAnimationDuration(animationSpeed);
applyChange();
setGraphChangeRevision((revision) => revision + 1);
},
- [cancelLayoutFrame],
+ [animationSpeed, cancelLayoutFrame],
);
// Apply each computed target to the currently rendered frame. New
diff --git a/query-graphs/test/animation-timing.test.ts b/query-graphs/test/animation-timing.test.ts
index f7768378..74afc5af 100644
--- a/query-graphs/test/animation-timing.test.ts
+++ b/query-graphs/test/animation-timing.test.ts
@@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import {getGraphAnimationDuration, graphAnimationProgress} from "../src/ui/animation-timing";
-import {settingsStore} from "../src/ui/settings";
test("the selected animation speed resolves to its duration unless motion is disabled", () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -11,12 +10,10 @@ test("the selected animation speed resolves to its duration unless motion is dis
value: {matchMedia: () => ({matches: reducedMotion})},
});
try {
- settingsStore.getState().setSettings({animationSpeed: "slow"});
- assert.equal(getGraphAnimationDuration(), 500);
+ assert.equal(getGraphAnimationDuration("slow"), 500);
reducedMotion = true;
- assert.equal(getGraphAnimationDuration(), undefined);
+ assert.equal(getGraphAnimationDuration("slow"), undefined);
} finally {
- settingsStore.getState().setSettings({animationSpeed: "medium"});
if (previousWindow === undefined) Reflect.deleteProperty(globalThis, "window");
else Object.defineProperty(globalThis, "window", previousWindow);
}
diff --git a/standalone-app/package.json b/standalone-app/package.json
index 333fdb5a..b90ae5af 100644
--- a/standalone-app/package.json
+++ b/standalone-app/package.json
@@ -35,7 +35,10 @@
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.5",
+ "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
+ "@replit/codemirror-emacs": "^6.1.0",
+ "@replit/codemirror-vim": "^6.4.0",
"@tableau/query-graphs": "workspace:^",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
@@ -58,6 +61,7 @@
"webpack-cli": "^7.2.2",
"webpack-dev-server": "^6.0.0",
"webpack-merge": "^6.0.1",
- "workbox-webpack-plugin": "^7.4.1"
+ "workbox-webpack-plugin": "^7.4.1",
+ "zustand": "^5.0.0"
}
}
diff --git a/standalone-app/src/CodeMirrorDocument.tsx b/standalone-app/src/CodeMirrorDocument.tsx
index 2d2de16d..1affc2cb 100644
--- a/standalone-app/src/CodeMirrorDocument.tsx
+++ b/standalone-app/src/CodeMirrorDocument.tsx
@@ -1,10 +1,15 @@
-import {useEffect, useRef} from "react";
+import {useEffect, useRef, useSyncExternalStore} from "react";
import {defaultKeymap} from "@codemirror/commands";
import {bracketMatching, defaultHighlightStyle, foldGutter, foldKeymap, syntaxHighlighting} from "@codemirror/language";
import {openSearchPanel, searchKeymap} from "@codemirror/search";
-import {EditorState, type Extension} from "@codemirror/state";
+import {Compartment, EditorState, type Extension} from "@codemirror/state";
+import {color as oneDarkColor, oneDark} from "@codemirror/theme-one-dark";
import {EditorView, drawSelection, highlightSpecialChars, keymap, lineNumbers, scrollPastEnd} from "@codemirror/view";
+import {emacs} from "@replit/codemirror-emacs";
+import {vim} from "@replit/codemirror-vim";
import type {SourceLocation, TextDocument} from "@tableau/query-graphs/lib/tree-description";
+import {useSettings} from "./settings";
+import type {EditorKeybindings, Theme} from "./settings";
import {compactSearch} from "./CodeMirrorSearch";
import {rangeLinking} from "./RangeLinking";
import "./CodeMirrorDocument.css";
@@ -64,7 +69,67 @@ const documentTheme = EditorView.theme({
overflow: "auto",
},
});
-const noLanguageExtension: Extension = [];
+const emptyExtension: Extension = [];
+
+interface EditorThemeColors {
+ hoverBackground: string;
+ activeBackground: string;
+ activeBorder: string;
+ linkedRange: string;
+ highlightedRangeBackground: string;
+}
+
+/** Exposes a theme's palette through semantic colors shared by editor extensions. */
+function withEditorThemeColors(theme: Extension, colors: EditorThemeColors): Extension {
+ return [
+ theme,
+ EditorView.theme({
+ "&": {
+ "--qg-control-hover-background": colors.hoverBackground,
+ "--qg-control-active-background": colors.activeBackground,
+ "--qg-control-active-border": colors.activeBorder,
+ "--qg-linked-range-color": colors.linkedRange,
+ "--qg-highlighted-range-background": colors.highlightedRangeBackground,
+ },
+ }),
+ ];
+}
+
+const oneDarkWithEditorThemeColors = withEditorThemeColors(oneDark, {
+ hoverBackground: oneDarkColor.highlightBackground,
+ activeBackground: oneDarkColor.selection,
+ activeBorder: oneDarkColor.cursor,
+ linkedRange: oneDarkColor.malibu,
+ highlightedRangeBackground: oneDarkColor.selection,
+});
+const darkThemeQuery = "(prefers-color-scheme: dark)";
+const themeConfiguration = new Compartment();
+const keybindingsConfiguration = new Compartment();
+const standardKeybindings = keymap.of([...defaultKeymap, ...searchKeymap, ...foldKeymap]);
+const keybindings: Record = {
+ standard: standardKeybindings,
+ // Alternative keybindings must precede the fallback keymap so they can handle
+ // overlapping shortcuts first.
+ vim: [vim(), standardKeybindings],
+ emacs: [emacs(), standardKeybindings],
+};
+// Third-party keymaps do not consistently honor CodeMirror's read-only facet.
+const preventDocumentChanges = EditorState.changeFilter.of((transaction) => !transaction.docChanged);
+
+function subscribeToSystemTheme(onStoreChange: () => void): () => void {
+ const mediaQuery = window.matchMedia(darkThemeQuery);
+ mediaQuery.addEventListener("change", onStoreChange);
+ return () => mediaQuery.removeEventListener("change", onStoreChange);
+}
+
+function getSystemTheme(): Exclude {
+ return window.matchMedia(darkThemeQuery).matches ? "dark" : "light";
+}
+
+function useResolvedTheme(theme: Theme): Exclude {
+ const currentSystemTheme = useSyncExternalStore(subscribeToSystemTheme, getSystemTheme, () => "light" as const);
+ return theme === "system" ? currentSystemTheme : theme;
+}
export interface DocumentViewerProps {
document: TextDocument;
@@ -82,7 +147,7 @@ export interface CodeMirrorDocumentProps extends DocumentViewerProps {
export function CodeMirrorDocument({
document: textDocument,
- languageExtension = noLanguageExtension,
+ languageExtension = emptyExtension,
linkedRanges = [],
highlightedRanges = [],
onActiveLinkedRangesChange,
@@ -92,6 +157,24 @@ export function CodeMirrorDocument({
const editorView = useRef(undefined);
const linkedRangesRef = useRef(linkedRanges);
const highlightedRangesRef = useRef(highlightedRanges);
+ const theme = useSettings((settings) => settings.values.theme);
+ const editorKeybindings = useSettings((settings) => settings.values.editorKeybindings);
+ const themeExtension = useResolvedTheme(theme) === "dark" ? oneDarkWithEditorThemeColors : emptyExtension;
+ const themeExtensionRef = useRef(themeExtension);
+ const keybindingsExtensionRef = useRef(keybindings[editorKeybindings]);
+
+ // Reconfigure the current editor in place and initialize replacement
+ // documents from the same latest preferences.
+ useEffect(() => {
+ themeExtensionRef.current = themeExtension;
+ keybindingsExtensionRef.current = keybindings[editorKeybindings];
+ editorView.current?.dispatch({
+ effects: [
+ themeConfiguration.reconfigure(themeExtension),
+ keybindingsConfiguration.reconfigure(keybindings[editorKeybindings]),
+ ],
+ });
+ }, [themeExtension, editorKeybindings]);
useEffect(() => {
linkedRangesRef.current = linkedRanges;
@@ -107,6 +190,7 @@ export function CodeMirrorDocument({
extensions: [
languageExtension,
syntaxHighlighting(defaultHighlightStyle, {fallback: true}),
+ themeConfiguration.of(themeExtensionRef.current),
lineNumbers(),
highlightSpecialChars(),
drawSelection(),
@@ -117,8 +201,9 @@ export function CodeMirrorDocument({
foldMarkerTheme,
documentTheme,
rangeLinking.extension(onActiveLinkedRangesChange),
- keymap.of([...defaultKeymap, ...searchKeymap, ...foldKeymap]),
+ keybindingsConfiguration.of(keybindingsExtensionRef.current),
EditorState.readOnly.of(true),
+ preventDocumentChanges,
EditorView.editable.of(false),
EditorView.contentAttributes.of({"aria-label": textDocument.title, tabindex: "0"}),
],
diff --git a/standalone-app/src/CodeMirrorSearch.ts b/standalone-app/src/CodeMirrorSearch.ts
index 5432ce3f..48100aff 100644
--- a/standalone-app/src/CodeMirrorSearch.ts
+++ b/standalone-app/src/CodeMirrorSearch.ts
@@ -171,7 +171,14 @@ class CompactSearchPanel implements Panel {
}
}
-const compactSearchTheme = EditorView.theme({
+const compactSearchTheme = EditorView.baseTheme({
+ "&": {
+ // These fallbacks adapt to any theme's foreground color. Themes with
+ // an exposed palette can override them with more specific colors.
+ "--qg-control-hover-background": "color-mix(in srgb, currentColor 10%, transparent)",
+ "--qg-control-active-background": "color-mix(in srgb, currentColor 18%, transparent)",
+ "--qg-control-active-border": "currentColor",
+ },
".cm-panel.cm-search": {
display: "flex",
alignItems: "center",
@@ -210,7 +217,7 @@ const compactSearchTheme = EditorView.theme({
cursor: "pointer",
},
".cm-panel.cm-search button:hover:not(:disabled)": {
- backgroundColor: "hsl(0, 0%, 90%)",
+ backgroundColor: "var(--qg-control-hover-background)",
},
".cm-panel.cm-search button:focus-visible": {
outline: "2px solid hsl(210, 90%, 65%)",
@@ -249,15 +256,15 @@ const compactSearchTheme = EditorView.theme({
userSelect: "none",
},
".cm-panel.cm-search .compact-search-toggle:hover": {
- backgroundColor: "hsl(0, 0%, 90%)",
+ backgroundColor: "var(--qg-control-hover-background)",
},
".cm-panel.cm-search .compact-search-toggle:has(input:focus-visible)": {
outline: "2px solid hsl(210, 90%, 65%)",
outlineOffset: "-2px",
},
".cm-panel.cm-search .compact-search-toggle:has(input:checked)": {
- borderColor: "hsl(210, 70%, 65%)",
- backgroundColor: "hsl(210, 90%, 92%)",
+ borderColor: "var(--qg-control-active-border)",
+ backgroundColor: "var(--qg-control-active-background)",
},
".cm-panel.cm-search .compact-search-toggle span": {
fontSize: "0.75rem",
diff --git a/standalone-app/src/QueryGraphsApp.tsx b/standalone-app/src/QueryGraphsApp.tsx
index e50d0717..4421c540 100644
--- a/standalone-app/src/QueryGraphsApp.tsx
+++ b/standalone-app/src/QueryGraphsApp.tsx
@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useState} from "react";
+import {useCallback, useEffect, useId, useState} from "react";
import {useBrowserUrl, useUrlParam} from "./browserUrlHooks";
import type {FileOpenerData} from "./FileOpener";
import {FileOpener, useLoadStateController} from "./FileOpener";
@@ -9,8 +9,12 @@ import {QueryGraph} from "@tableau/query-graphs/lib/ui/QueryGraph";
import {tryCreateLocalStorageUrl, isLocalStorageURL, loadLocalStorageURL} from "./LocalStorageUrl";
import {assert} from "./assert";
import {TreeLabel} from "./TreeLabel";
+import {SettingsButton, SettingsPanel} from "./SettingsPanel";
+import {useSettings} from "./settings";
export function QueryGraphsApp() {
+ const settingsPopoverId = useId();
+ const animationSpeed = useSettings((settings) => settings.values.animationSpeed);
const loadStateController = useLoadStateController();
const {setProgress, clearLoadState, tryAndDisplayErrors} = loadStateController;
const [loadedPlan, setLoadedPlan] = useState(undefined);
@@ -145,7 +149,11 @@ export function QueryGraphsApp() {
} else {
const {tree, format} = loadedPlan;
return (
-
+ }
+ >
+
);
}
diff --git a/standalone-app/src/RangeLinking.ts b/standalone-app/src/RangeLinking.ts
index 44ef66df..8e0d6992 100644
--- a/standalone-app/src/RangeLinking.ts
+++ b/standalone-app/src/RangeLinking.ts
@@ -32,8 +32,16 @@ const linkedRangeDecorations = rangeDecorationField(setLinkedRanges, "cm-linked-
const highlightedRangeDecorations = rangeDecorationField(setHighlightedRanges, "cm-highlighted-range");
const rangeLinkingTheme = EditorView.baseTheme({
+ "&": {
+ "--qg-linked-range-color": "hsl(210, 55%, 55%)",
+ "--qg-highlighted-range-background": "hsl(210, 100%, 85%)",
+ },
+ "&dark": {
+ "--qg-linked-range-color": "currentColor",
+ "--qg-highlighted-range-background": "color-mix(in srgb, currentColor 18%, transparent)",
+ },
".cm-linked-range": {
- textDecoration: "underline dotted hsl(210, 55%, 55%)",
+ textDecoration: "underline dotted var(--qg-linked-range-color)",
textUnderlineOffset: ".18em",
},
".cm-linked-range:hover": {
@@ -41,7 +49,7 @@ const rangeLinkingTheme = EditorView.baseTheme({
textDecorationThickness: "2px",
},
".cm-highlighted-range": {
- background: "hsl(210, 100%, 85%)",
+ background: "var(--qg-highlighted-range-background)",
borderRadius: ".15em",
},
".cm-foldPlaceholder.cm-fold-hides-highlighted-range": {
@@ -49,19 +57,19 @@ const rangeLinkingTheme = EditorView.baseTheme({
},
"@keyframes cm-folded-range-highlight-pulse": {
from: {
- backgroundColor: "hsl(210, 100%, 90%)",
- borderColor: "hsl(210, 80%, 55%)",
+ backgroundColor: "color-mix(in srgb, var(--qg-linked-range-color) 12%, transparent)",
+ borderColor: "color-mix(in srgb, var(--qg-linked-range-color) 75%, transparent)",
},
to: {
- backgroundColor: "hsl(210, 100%, 70%)",
- borderColor: "hsl(210, 90%, 40%)",
+ backgroundColor: "color-mix(in srgb, var(--qg-linked-range-color) 35%, transparent)",
+ borderColor: "var(--qg-linked-range-color)",
},
},
"@media (prefers-reduced-motion: reduce)": {
".cm-foldPlaceholder.cm-fold-hides-highlighted-range": {
animation: "none",
- backgroundColor: "hsl(210, 100%, 80%)",
- borderColor: "hsl(210, 80%, 45%)",
+ backgroundColor: "var(--qg-highlighted-range-background)",
+ borderColor: "var(--qg-linked-range-color)",
},
},
"@media (forced-colors: active)": {
diff --git a/query-graphs/src/ui/SettingsPanel.css b/standalone-app/src/SettingsPanel.css
similarity index 82%
rename from query-graphs/src/ui/SettingsPanel.css
rename to standalone-app/src/SettingsPanel.css
index fc525807..69d51972 100644
--- a/query-graphs/src/ui/SettingsPanel.css
+++ b/standalone-app/src/SettingsPanel.css
@@ -8,10 +8,10 @@
--qg-settings-focus: hsl(210, 90%, 55%);
position: fixed;
- inset: auto auto 15px calc(15px + 26px + 0.5rem);
+ inset: auto auto 15px calc(15px + 26px + 0.5em);
box-sizing: border-box;
- inline-size: min(22rem, calc(100vw - 2rem));
- max-block-size: calc(100dvh - 2rem);
+ inline-size: min(22em, calc(100vw - 2em));
+ max-block-size: calc(100dvh - 2em);
margin: 0;
padding: 0;
overflow: auto;
@@ -25,12 +25,12 @@
}
.qg-settings-body {
- font-size: 0.875rem;
+ font-size: 0.875em;
line-height: 1.4;
}
.qg-settings-note {
- margin: -0.4rem 0.75rem 0.9rem;
+ margin: -0.2em 0.75em 0.6em;
color: hsl(0, 0%, 40%);
font-size: 0.85em;
}
@@ -39,7 +39,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.5em 0.75rem;
+ padding: 0.5em 0.75em;
border-block-end: 1px solid var(--qg-settings-divider);
& h2 {
@@ -53,11 +53,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
- inline-size: 1.75rem;
- block-size: 1.75rem;
+ inline-size: 1.75em;
+ block-size: 1.75em;
padding: 0;
border: 0;
- border-radius: 0.25rem;
+ border-radius: 0.25em;
color: inherit;
background: transparent;
cursor: pointer;
@@ -67,8 +67,8 @@
}
& svg {
- inline-size: 1rem;
- block-size: 1rem;
+ inline-size: 1em;
+ block-size: 1em;
fill: none;
stroke: currentColor;
stroke-width: 2;
@@ -80,22 +80,22 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: 1rem;
- padding: 0.9rem 0.75rem;
+ gap: 1em;
+ padding: 0.4em 0.75em;
& select {
- min-inline-size: 9rem;
- padding: 0.25rem;
+ min-inline-size: 9em;
+ padding: 0.25em;
color: inherit;
background: var(--qg-settings-surface);
border: 1px solid var(--qg-settings-border);
- border-radius: 0.25rem;
+ border-radius: 0.25em;
font: inherit;
}
}
.qg-settings-footer {
- padding: 0.65rem 0.75rem;
+ padding: 0.65em 0.75em;
border-block-start: 1px solid var(--qg-settings-divider);
& button {
@@ -125,26 +125,26 @@
@supports (position-area: inline-end) {
.qg-settings-popover {
inset: auto;
- margin-inline-start: 0.5rem;
+ margin-inline-start: 0.5em;
position-area: inline-end span-block-start;
position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline;
}
}
-@media (max-width: 32rem) {
+@media (max-width: 32em) {
.qg-settings-popover {
inset: auto 0 0;
inline-size: 100%;
max-block-size: 80dvh;
margin: 0;
- border-radius: 0.75rem 0.75rem 0 0;
+ border-radius: 0.75em 0.75em 0 0;
position-area: none;
}
.qg-settings-row {
align-items: stretch;
flex-direction: column;
- gap: 0.35rem;
+ gap: 0.35em;
}
}
diff --git a/query-graphs/src/ui/SettingsPanel.tsx b/standalone-app/src/SettingsPanel.tsx
similarity index 64%
rename from query-graphs/src/ui/SettingsPanel.tsx
rename to standalone-app/src/SettingsPanel.tsx
index 4093761b..f18843ee 100644
--- a/query-graphs/src/ui/SettingsPanel.tsx
+++ b/standalone-app/src/SettingsPanel.tsx
@@ -1,7 +1,8 @@
-import {ControlButton} from "@xyflow/react";
-import {usePrefersReducedMotion} from "./animation-timing";
-import {animationSpeedOptions, useSettings} from "./settings";
-import type {AnimationSpeed} from "./settings";
+import {animationSpeedOptions, usePrefersReducedMotion} from "@tableau/query-graphs/lib/ui/animation-timing";
+import type {AnimationSpeed} from "@tableau/query-graphs/lib/ui/animation-timing";
+import {QueryGraphControlButton} from "@tableau/query-graphs/lib/ui/QueryGraph";
+import {editorKeybindingsOptions, themeOptions, useSettings} from "./settings";
+import type {EditorKeybindings, Theme} from "./settings";
import "./SettingsPanel.css";
interface SettingsTargetProps {
@@ -10,7 +11,7 @@ interface SettingsTargetProps {
export function SettingsButton({popoverId}: SettingsTargetProps) {
return (
-
))}
-
+
);
}
export function SettingsPanel({popoverId}: SettingsTargetProps) {
- const animationSpeed = useSettings((settings) => settings.values.animationSpeed);
+ const {animationSpeed, theme, editorKeybindings} = useSettings((settings) => settings.values);
const setSettings = useSettings((settings) => settings.setSettings);
const resetSettings = useSettings((settings) => settings.resetSettings);
const reducedMotion = usePrefersReducedMotion();
@@ -71,6 +72,29 @@ export function SettingsPanel({popoverId}: SettingsTargetProps) {
Animations are disabled by your system’s reduced-motion setting.
) : null}
+
+