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..421eb6bd6ea 100644 --- a/packages/compass-data-modeling/src/utils/utils.ts +++ b/packages/compass-data-modeling/src/utils/utils.ts @@ -48,3 +48,39 @@ 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. +// 'count' specifies how many different source handlers are generated +// in the returned array. +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)); +}