Skip to content

Commit ad6e7cd

Browse files
authored
feat(data-modeling): add undo/redo application menu interactions COMPASS-9976 (#7494)
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.
1 parent 9b1a734 commit ad6e7cd

File tree

7 files changed

+168
-20
lines changed

7 files changed

+168
-20
lines changed

packages/compass-components/src/hooks/use-focus-hover.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from '@react-aria/interactions';
66
import { mergeProps } from '@react-aria/utils';
77
import type React from 'react';
8-
import { useMemo, useRef, useState } from 'react';
8+
import { useEffect, useMemo, useRef, useState } from 'react';
99

1010
export enum FocusState {
1111
NoFocus = 'NoFocus',
@@ -57,6 +57,53 @@ export function useFocusState(): [
5757
return [mergedProps, focusStateRef.current, focusStateRef];
5858
}
5959

60+
function checkBodyFocused(): boolean {
61+
const { documentElement, activeElement, body } = document;
62+
return (
63+
activeElement === documentElement ||
64+
activeElement === body ||
65+
!activeElement
66+
);
67+
}
68+
69+
function useIsDocumentUnfocused() {
70+
const [isBodyFocused, setIsBodyFocused] = useState(checkBodyFocused());
71+
72+
useEffect(() => {
73+
const cleanup: (() => void)[] = [];
74+
const listener = () => {
75+
setIsBodyFocused(checkBodyFocused());
76+
};
77+
for (const el of [document.body, document.documentElement]) {
78+
for (const ev of ['focus', 'blur', 'focusin', 'focusout']) {
79+
el.addEventListener(ev, listener);
80+
cleanup.push(() => el.removeEventListener(ev, listener));
81+
}
82+
}
83+
return () => {
84+
for (const cb of cleanup) {
85+
cb();
86+
}
87+
};
88+
}, [setIsBodyFocused]);
89+
90+
return isBodyFocused;
91+
}
92+
93+
export function useFocusStateIncludingUnfocused(): [
94+
React.HTMLAttributes<HTMLElement>,
95+
FocusState | 'Unfocused',
96+
React.MutableRefObject<FocusState | 'Unfocused'>
97+
] {
98+
const focusStateRef = useRef<FocusState | 'Unfocused'>(FocusState.NoFocus);
99+
const [props, state] = useFocusState();
100+
const isUnfocused = useIsDocumentUnfocused();
101+
const extendedState = isUnfocused ? 'Unfocused' : state;
102+
103+
focusStateRef.current = extendedState;
104+
return [props, extendedState, focusStateRef];
105+
}
106+
60107
export function useHoverState(): [
61108
React.HTMLAttributes<HTMLElement>,
62109
boolean,

packages/compass-components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export {
141141
};
142142
export {
143143
useFocusState,
144+
useFocusStateIncludingUnfocused,
144145
useHoverState,
145146
FocusState,
146147
} from './hooks/use-focus-hover';

packages/compass-data-modeling/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@mongodb-js/compass-app-stores": "^7.70.0",
6060
"@mongodb-js/compass-components": "^1.56.0",
6161
"@mongodb-js/compass-connections": "^1.84.0",
62+
"@mongodb-js/compass-electron-menu": "^0.1.0",
6263
"@mongodb-js/compass-logging": "^1.7.22",
6364
"@mongodb-js/compass-telemetry": "^1.19.0",
6465
"@mongodb-js/compass-user-data": "^0.10.5",

packages/compass-data-modeling/src/components/diagram-editor-toolbar.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
} from '@mongodb-js/compass-components';
2121
import AddCollection from './icons/add-collection';
2222
import { useOpenWorkspace } from '@mongodb-js/compass-workspaces/provider';
23+
import { useApplicationMenu } from '@mongodb-js/compass-electron-menu';
24+
import { dualSourceHandlerDebounce } from '../utils/utils';
2325

2426
const breadcrumbsStyles = css({
2527
padding: `${spacing[300]}px ${spacing[400]}px`,
@@ -55,6 +57,7 @@ export const DiagramEditorToolbar: React.FunctionComponent<{
5557
diagramName?: string;
5658
hasUndo: boolean;
5759
hasRedo: boolean;
60+
diagramEditorHasFocus?: boolean;
5861
isInRelationshipDrawingMode: boolean;
5962
onUndoClick: () => void;
6063
onRedoClick: () => void;
@@ -67,6 +70,7 @@ export const DiagramEditorToolbar: React.FunctionComponent<{
6770
hasUndo,
6871
onUndoClick,
6972
hasRedo,
73+
diagramEditorHasFocus,
7074
onRedoClick,
7175
onExportClick,
7276
onRelationshipDrawingToggle,
@@ -87,19 +91,41 @@ export const DiagramEditorToolbar: React.FunctionComponent<{
8791
[diagramName, openDataModelingWorkspace]
8892
);
8993

90-
// TODO(COMPASS-9976): Integrate with application menu
94+
// Use dualSourceHandlerDebounce to avoid handling the same keypresses
95+
// coming through useHotkeys and the application menu.
96+
const [undoHotkey, undoAppMenu] = useMemo(
97+
() => dualSourceHandlerDebounce(onUndoClick),
98+
[onUndoClick]
99+
);
100+
const [redoHotkey, redoAppMenu] = useMemo(
101+
() => dualSourceHandlerDebounce(onRedoClick),
102+
[onRedoClick]
103+
);
104+
91105
// macOS: Cmd+Shift+Z = Redo, Cmd+Z = Undo
92106
// Windows/Linux: Ctrl+Z = Undo, Ctrl+Y = Redo
93-
useHotkeys('mod+z', onUndoClick, { enabled: step === 'EDITING' }, [
94-
onUndoClick,
107+
useHotkeys('mod+z', undoHotkey, { enabled: step === 'EDITING' }, [
108+
undoHotkey,
95109
]);
96-
useHotkeys('mod+shift+z', onRedoClick, { enabled: step === 'EDITING' }, [
97-
onRedoClick,
110+
useHotkeys('mod+shift+z', redoHotkey, { enabled: step === 'EDITING' }, [
111+
redoHotkey,
98112
]);
99-
useHotkeys('mod+y', onRedoClick, { enabled: step === 'EDITING' }, [
100-
onRedoClick,
113+
useHotkeys('mod+y', redoHotkey, { enabled: step === 'EDITING' }, [
114+
redoHotkey,
101115
]);
102116

117+
// Take over the undo/redo functionality in the application menu
118+
// if either no element is focused or a child of the data modeling editor
119+
// view is focused.
120+
useApplicationMenu({
121+
roles: diagramEditorHasFocus
122+
? {
123+
undo: undoAppMenu,
124+
redo: redoAppMenu,
125+
}
126+
: {},
127+
});
128+
103129
if (step !== 'EDITING') {
104130
return null;
105131
}

packages/compass-data-modeling/src/components/diagram-editor.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
Diagram,
4545
useDiagram,
4646
useHotkeys,
47+
FocusState,
48+
useFocusStateIncludingUnfocused,
4749
} from '@mongodb-js/compass-components';
4850
import { cancelAnalysis, retryAnalysis } from '../store/analysis-process';
4951
import type { FieldPath, StaticModel } from '../services/data-model-storage';
@@ -137,6 +139,10 @@ const modelPreviewStyles = css({
137139
},
138140
});
139141

142+
const displayContentsStyles = css({
143+
display: 'contents',
144+
});
145+
140146
const ZOOM_OPTIONS = {
141147
maxZoom: 1,
142148
minZoom: 0.25,
@@ -527,6 +533,8 @@ const DiagramEditor: React.FunctionComponent<{
527533
openDrawer(DATA_MODELING_DRAWER_ID);
528534
}, [openDrawer, onAddCollectionClick]);
529535

536+
const [focusProps, focusState] = useFocusStateIncludingUnfocused();
537+
530538
if (step === 'NO_DIAGRAM_SELECTED') {
531539
return null;
532540
}
@@ -572,18 +580,21 @@ const DiagramEditor: React.FunctionComponent<{
572580
}
573581

574582
return (
575-
<WorkspaceContainer
576-
toolbar={
577-
<DiagramEditorToolbar
578-
onRelationshipDrawingToggle={handleRelationshipDrawingToggle}
579-
isInRelationshipDrawingMode={isInRelationshipDrawingMode}
580-
onAddCollectionClick={handleAddCollectionClick}
581-
/>
582-
}
583-
>
584-
{content}
585-
<ExportDiagramModal />
586-
</WorkspaceContainer>
583+
<div className={displayContentsStyles} {...focusProps}>
584+
<WorkspaceContainer
585+
toolbar={
586+
<DiagramEditorToolbar
587+
diagramEditorHasFocus={focusState !== FocusState.NoFocus}
588+
onRelationshipDrawingToggle={handleRelationshipDrawingToggle}
589+
isInRelationshipDrawingMode={isInRelationshipDrawingMode}
590+
onAddCollectionClick={handleAddCollectionClick}
591+
/>
592+
}
593+
>
594+
{content}
595+
<ExportDiagramModal />
596+
</WorkspaceContainer>
597+
</div>
587598
);
588599
};
589600

packages/compass-data-modeling/src/utils/utils.spec.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
isRelationshipInvolvingField,
44
isRelationshipOfAField,
55
isSameFieldOrAncestor,
6+
dualSourceHandlerDebounce,
67
} from './utils';
78
import type { Relationship } from '../services/data-model-storage';
89

@@ -109,3 +110,28 @@ describe('isRelationshipInvolvingAField', function () {
109110
).to.be.true;
110111
});
111112
});
113+
114+
describe('dualSourceHandlerDebounce', function () {
115+
it('should invoke the original handler only once for dual invocations', function () {
116+
const timestamps = [0, 0, 200, 400, 401];
117+
let invocationCount = 0;
118+
const handler = () => {
119+
invocationCount++;
120+
};
121+
const [handler1, handler2] = dualSourceHandlerDebounce(
122+
handler,
123+
2,
124+
() => timestamps.shift()!
125+
);
126+
handler1();
127+
expect(invocationCount).to.equal(1);
128+
handler2();
129+
expect(invocationCount).to.equal(1);
130+
handler1();
131+
expect(invocationCount).to.equal(2);
132+
handler2();
133+
expect(invocationCount).to.equal(3);
134+
handler1();
135+
expect(invocationCount).to.equal(3);
136+
});
137+
});

packages/compass-data-modeling/src/utils/utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,39 @@ export function isRelationshipInvolvingField(
4848
isSameFieldOrAncestor(fieldPath, foreign.fields))
4949
);
5050
}
51+
52+
// Sometimes, we may receive the same event through different sources.
53+
// For example, Undo/Redo may be caught both by a HTML hotkey listener
54+
// and the Electron menu accelerator. This debounce function helps
55+
// to avoid invoking the handler multiple times in such cases.
56+
// 'count' specifies how many different source handlers are generated
57+
// in the returned array.
58+
export function dualSourceHandlerDebounce(
59+
handler: () => void,
60+
count = 2,
61+
now = Date.now
62+
): (() => void)[] {
63+
let lastInvocationSource: number = -1;
64+
let lastInvocationTime: number = -1;
65+
const makeHandler = (index: number): (() => void) => {
66+
return () => {
67+
const priorInvocationTime = lastInvocationTime;
68+
lastInvocationTime = now();
69+
70+
// Call the current handler if:
71+
// - It was the last one to be invoked (i.e. it "owns" this callback), or
72+
// - No handler was ever invoked yet, or
73+
// - Enough time has passed that it's unlikely that we just received
74+
// the same event as in the last call.
75+
if (
76+
lastInvocationSource === index ||
77+
lastInvocationSource === -1 ||
78+
lastInvocationTime - priorInvocationTime > 100
79+
) {
80+
lastInvocationSource = index;
81+
handler();
82+
}
83+
};
84+
};
85+
return Array.from({ length: count }, (_, i) => makeHandler(i));
86+
}

0 commit comments

Comments
 (0)