From 44a76092aa4e7b71813f150ea0a9895cda9c8ec7 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 23 Oct 2025 01:54:41 +0200 Subject: [PATCH 1/2] feat(data-modeling): add undo/redo application menu interactions This requires adding some machinery to track whether undo/redo events should apply to the current diagram (by testing whether no element outside of the diagram is focused), as well as accounting for the fact that the diagram also has hotkey handlers that can be triggered by the same keyboard presses. --- .../src/hooks/use-focus-hover.ts | 49 ++++++++++++++++++- packages/compass-components/src/index.ts | 1 + packages/compass-data-modeling/package.json | 1 + .../src/components/diagram-editor-toolbar.tsx | 40 ++++++++++++--- .../src/components/diagram-editor.tsx | 35 ++++++++----- .../src/utils/utils.spec.tsx | 26 ++++++++++ .../compass-data-modeling/src/utils/utils.ts | 34 +++++++++++++ 7 files changed, 166 insertions(+), 20 deletions(-) diff --git a/packages/compass-components/src/hooks/use-focus-hover.ts b/packages/compass-components/src/hooks/use-focus-hover.ts index e5f31a4c23e..7fac0e30bf0 100644 --- a/packages/compass-components/src/hooks/use-focus-hover.ts +++ b/packages/compass-components/src/hooks/use-focus-hover.ts @@ -5,7 +5,7 @@ import { } from '@react-aria/interactions'; import { mergeProps } from '@react-aria/utils'; import type React from 'react'; -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; export enum FocusState { NoFocus = 'NoFocus', @@ -57,6 +57,53 @@ export function useFocusState(): [ return [mergedProps, focusStateRef.current, focusStateRef]; } +function checkBodyFocused(): boolean { + const { documentElement, activeElement, body } = document; + return ( + activeElement === documentElement || + activeElement === body || + !activeElement + ); +} + +function useIsDocumentUnfocused() { + const [isBodyFocused, setIsBodyFocused] = useState(checkBodyFocused()); + + useEffect(() => { + const cleanup: (() => void)[] = []; + const listener = () => { + setIsBodyFocused(checkBodyFocused()); + }; + for (const el of [document.body, document.documentElement]) { + for (const ev of ['focus', 'blur', 'focusin', 'focusout']) { + el.addEventListener(ev, listener); + cleanup.push(() => el.removeEventListener(ev, listener)); + } + } + return () => { + for (const cb of cleanup) { + cb(); + } + }; + }, [setIsBodyFocused]); + + return isBodyFocused; +} + +export function useFocusStateIncludingUnfocused(): [ + React.HTMLAttributes, + FocusState | 'Unfocused', + React.MutableRefObject +] { + const focusStateRef = useRef(FocusState.NoFocus); + const [props, state] = useFocusState(); + const isUnfocused = useIsDocumentUnfocused(); + const extendedState = isUnfocused ? 'Unfocused' : state; + + focusStateRef.current = extendedState; + return [props, extendedState, focusStateRef]; +} + export function useHoverState(): [ React.HTMLAttributes, boolean, diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 6baf8fd443a..fea9826c964 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -141,6 +141,7 @@ export { }; export { useFocusState, + useFocusStateIncludingUnfocused, useHoverState, FocusState, } from './hooks/use-focus-hover'; diff --git a/packages/compass-data-modeling/package.json b/packages/compass-data-modeling/package.json index c8acc0262b7..7e7d9cba19c 100644 --- a/packages/compass-data-modeling/package.json +++ b/packages/compass-data-modeling/package.json @@ -59,6 +59,7 @@ "@mongodb-js/compass-app-stores": "^7.70.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.84.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.19.0", "@mongodb-js/compass-user-data": "^0.10.5", diff --git a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx index e4182196cb4..31750755507 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx @@ -20,6 +20,8 @@ import { } from '@mongodb-js/compass-components'; import AddCollection from './icons/add-collection'; import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider'; +import { useApplicationMenu } from '@mongodb-js/compass-electron-menu'; +import { dualSourceHandlerDebounce } from '../utils/utils'; const breadcrumbsStyles = css({ padding: `${spacing[300]}px ${spacing[400]}px`, @@ -55,6 +57,7 @@ export const DiagramEditorToolbar: React.FunctionComponent<{ diagramName?: string; hasUndo: boolean; hasRedo: boolean; + diagramEditorHasFocus?: boolean; isInRelationshipDrawingMode: boolean; onUndoClick: () => void; onRedoClick: () => void; @@ -67,6 +70,7 @@ export const DiagramEditorToolbar: React.FunctionComponent<{ hasUndo, onUndoClick, hasRedo, + diagramEditorHasFocus, onRedoClick, onExportClick, onRelationshipDrawingToggle, @@ -87,19 +91,41 @@ export const DiagramEditorToolbar: React.FunctionComponent<{ [diagramName, openDataModelingWorkspace] ); - // TODO(COMPASS-9976): Integrate with application menu + // Use dualSourceHandlerDebounce to avoid handling the same keypresses + // coming through useHotkeys and the application menu. + const [undoHotkey, undoAppMenu] = useMemo( + () => dualSourceHandlerDebounce(onUndoClick), + [onUndoClick] + ); + const [redoHotkey, redoAppMenu] = useMemo( + () => dualSourceHandlerDebounce(onRedoClick), + [onRedoClick] + ); + // macOS: Cmd+Shift+Z = Redo, Cmd+Z = Undo // Windows/Linux: Ctrl+Z = Undo, Ctrl+Y = Redo - useHotkeys('mod+z', onUndoClick, { enabled: step === 'EDITING' }, [ - onUndoClick, + useHotkeys('mod+z', undoHotkey, { enabled: step === 'EDITING' }, [ + undoHotkey, ]); - useHotkeys('mod+shift+z', onRedoClick, { enabled: step === 'EDITING' }, [ - onRedoClick, + useHotkeys('mod+shift+z', redoHotkey, { enabled: step === 'EDITING' }, [ + redoHotkey, ]); - useHotkeys('mod+y', onRedoClick, { enabled: step === 'EDITING' }, [ - onRedoClick, + useHotkeys('mod+y', redoHotkey, { enabled: step === 'EDITING' }, [ + redoHotkey, ]); + // Take over the undo/redo functionality in the application menu + // if either no element is focused or a child of the data modeling editor + // view is focused. + useApplicationMenu({ + roles: diagramEditorHasFocus + ? { + undo: undoAppMenu, + redo: redoAppMenu, + } + : {}, + }); + if (step !== 'EDITING') { return null; } diff --git a/packages/compass-data-modeling/src/components/diagram-editor.tsx b/packages/compass-data-modeling/src/components/diagram-editor.tsx index 5009c350d93..d990c5cefa7 100644 --- a/packages/compass-data-modeling/src/components/diagram-editor.tsx +++ b/packages/compass-data-modeling/src/components/diagram-editor.tsx @@ -44,6 +44,8 @@ import { Diagram, useDiagram, useHotkeys, + FocusState, + useFocusStateIncludingUnfocused, } from '@mongodb-js/compass-components'; import { cancelAnalysis, retryAnalysis } from '../store/analysis-process'; import type { FieldPath, StaticModel } from '../services/data-model-storage'; @@ -124,6 +126,10 @@ const modelPreviewStyles = css({ }, }); +const displayContentsStyles = css({ + display: 'contents', +}); + const ZOOM_OPTIONS = { maxZoom: 1, minZoom: 0.25, @@ -513,6 +519,8 @@ const DiagramEditor: React.FunctionComponent<{ openDrawer(DATA_MODELING_DRAWER_ID); }, [openDrawer, onAddCollectionClick]); + const [focusProps, focusState] = useFocusStateIncludingUnfocused(); + if (step === 'NO_DIAGRAM_SELECTED') { return null; } @@ -558,18 +566,21 @@ const DiagramEditor: React.FunctionComponent<{ } return ( - - } - > - {content} - - +
+ + } + > + {content} + + +
); }; diff --git a/packages/compass-data-modeling/src/utils/utils.spec.tsx b/packages/compass-data-modeling/src/utils/utils.spec.tsx index ec61c6c4cac..31e4883d6fb 100644 --- a/packages/compass-data-modeling/src/utils/utils.spec.tsx +++ b/packages/compass-data-modeling/src/utils/utils.spec.tsx @@ -3,6 +3,7 @@ import { isRelationshipInvolvingField, isRelationshipOfAField, isSameFieldOrAncestor, + dualSourceHandlerDebounce, } from './utils'; import type { Relationship } from '../services/data-model-storage'; @@ -109,3 +110,28 @@ describe('isRelationshipInvolvingAField', function () { ).to.be.true; }); }); + +describe('dualSourceHandlerDebounce', function () { + it('should invoke the original handler only once for dual invocations', function () { + const timestamps = [0, 0, 200, 400, 401]; + let invocationCount = 0; + const handler = () => { + invocationCount++; + }; + const [handler1, handler2] = dualSourceHandlerDebounce( + handler, + 2, + () => timestamps.shift()! + ); + handler1(); + expect(invocationCount).to.equal(1); + handler2(); + expect(invocationCount).to.equal(1); + handler1(); + expect(invocationCount).to.equal(2); + handler2(); + expect(invocationCount).to.equal(3); + handler1(); + expect(invocationCount).to.equal(3); + }); +}); diff --git a/packages/compass-data-modeling/src/utils/utils.ts b/packages/compass-data-modeling/src/utils/utils.ts index 33937f9a12f..b07db94cd84 100644 --- a/packages/compass-data-modeling/src/utils/utils.ts +++ b/packages/compass-data-modeling/src/utils/utils.ts @@ -48,3 +48,37 @@ export function isRelationshipInvolvingField( isSameFieldOrAncestor(fieldPath, foreign.fields)) ); } + +// Sometimes, we may receive the same event through different sources. +// For example, Undo/Redo may be caught both by a HTML hotkey listener +// and the Electron menu accelerator. This debounce function helps +// to avoid invoking the handler multiple times in such cases. +export function dualSourceHandlerDebounce( + handler: () => void, + count = 2, + now = Date.now +): (() => void)[] { + let lastInvocationSource: number = -1; + let lastInvocationTime: number = -1; + const makeHandler = (index: number): (() => void) => { + return () => { + const priorInvocationTime = lastInvocationTime; + lastInvocationTime = now(); + + // Call the current handler if: + // - It was the last one to be invoked (i.e. it "owns" this callback), or + // - No handler was ever invoked yet, or + // - Enough time has passed that it's unlikely that we just received + // the same event as in the last call. + if ( + lastInvocationSource === index || + lastInvocationSource === -1 || + lastInvocationTime - priorInvocationTime > 100 + ) { + lastInvocationSource = index; + handler(); + } + }; + }; + return Array.from({ length: count }, (_, i) => makeHandler(i)); +} From 75ff41a15710c7dbd0edd450f5e45191634d7468 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Oct 2025 16:52:08 +0200 Subject: [PATCH 2/2] fixup: extra doc comment --- packages/compass-data-modeling/src/utils/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/compass-data-modeling/src/utils/utils.ts b/packages/compass-data-modeling/src/utils/utils.ts index b07db94cd84..421eb6bd6ea 100644 --- a/packages/compass-data-modeling/src/utils/utils.ts +++ b/packages/compass-data-modeling/src/utils/utils.ts @@ -53,6 +53,8 @@ export function isRelationshipInvolvingField( // For example, Undo/Redo may be caught both by a HTML hotkey listener // and the Electron menu accelerator. This debounce function helps // to avoid invoking the handler multiple times in such cases. +// 'count' specifies how many different source handlers are generated +// in the returned array. export function dualSourceHandlerDebounce( handler: () => void, count = 2,