Skip to content

Commit

Permalink
Add undo & redo functionality (#1225)
Browse files Browse the repository at this point in the history
Co-authored-by: Pedro Ferreira <10789765+apedroferreira@users.noreply.github.com>
  • Loading branch information
bytasv and apedroferreira authored Nov 3, 2022
1 parent ef5ad1b commit 0c0b7f8
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 11 deletions.
17 changes: 16 additions & 1 deletion packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Alert,
Box,
Button,
IconButton,
Dialog,
DialogActions,
DialogContent,
Expand All @@ -17,12 +18,15 @@ import SyncIcon from '@mui/icons-material/Sync';
import SyncProblemIcon from '@mui/icons-material/SyncProblem';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
import Undo from '@mui/icons-material/Undo';
import Redo from '@mui/icons-material/Redo';

import * as React from 'react';
import { useForm } from 'react-hook-form';
import { Outlet } from 'react-router-dom';
import invariant from 'invariant';
import DialogForm from '../../components/DialogForm';
import { DomLoader, useDomLoader } from '../DomLoader';
import { DomLoader, useDomLoader, useDomApi } from '../DomLoader';
import ToolpadShell from '../ToolpadShell';
import PagePanel from './PagePanel';
import client from '../../api';
Expand Down Expand Up @@ -137,6 +141,7 @@ export interface ToolpadShellProps {

export default function AppEditorShell({ appId, ...props }: ToolpadShellProps) {
const domLoader = useDomLoader();
const domApi = useDomApi();

const {
value: createReleaseDialogOpen,
Expand All @@ -148,6 +153,16 @@ export default function AppEditorShell({ appId, ...props }: ToolpadShellProps) {
<ToolpadShell
actions={
<Stack direction="row" gap={1} alignItems="center">
<IconButton onClick={domApi.undo}>
<Tooltip title="Undo">
<Undo />
</Tooltip>
</IconButton>
<IconButton onClick={domApi.redo}>
<Tooltip title="Redo">
<Redo />
</Tooltip>
</IconButton>
<Button
variant="outlined"
endIcon={<OpenInNewIcon />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export default function ComponentEditor({ className }: ComponentEditorProps) {

const { selection } = editor;

const selectedNode = selection ? appDom.getNode(dom, selection) : null;
const selectedNode = selection ? appDom.getMaybeNode(dom, selection) : null;

return (
<ComponentEditorRoot className={className} data-testid="component-editor">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { ToolpadBridge } from '../../../canvas';
import useEvent from '../../../utils/useEvent';
import { LogEntry } from '../../../components/Console';
import { Maybe } from '../../../utils/types';
import { useDomApi } from '../../DomLoader';
import { hasFieldFocus } from '../../../utils/fields';

type IframeContentWindow = Window & typeof globalThis;

Expand Down Expand Up @@ -79,6 +81,7 @@ export default React.forwardRef<EditorCanvasHostHandle, EditorCanvasHostProps>(
forwardedRef,
) {
const frameRef = React.useRef<HTMLIFrameElement>(null);
const domApi = useDomApi();

const [bridge, setBridge] = React.useState<ToolpadBridge | null>(null);

Expand Down Expand Up @@ -145,10 +148,44 @@ export default React.forwardRef<EditorCanvasHostHandle, EditorCanvasHostProps>(

const handleRuntimeEvent = useEvent(onRuntimeEvent);

const iframeKeyDownHandler = React.useCallback(
(iframeDocument: Document) => {
return (event: KeyboardEvent) => {
if (hasFieldFocus(iframeDocument)) {
return;
}

const { code, metaKey, shiftKey } = event;
const undoShortcut = code === 'KeyZ' && metaKey;
const redoShortcut = undoShortcut && shiftKey;

if (redoShortcut) {
domApi.redo();
} else if (undoShortcut) {
domApi.undo();
}
};
},
[domApi],
);

const handleFrameLoad = React.useCallback(() => {
invariant(frameRef.current, 'Iframe ref not attached');
setContentWindow(frameRef.current.contentWindow);
}, []);

const iframeWindow = frameRef.current.contentWindow;
setContentWindow(iframeWindow);

if (!iframeWindow) {
return;
}

const keyDownHandler = iframeKeyDownHandler(iframeWindow.document);

iframeWindow?.addEventListener('keydown', keyDownHandler);
iframeWindow?.addEventListener('unload', () => {
iframeWindow?.removeEventListener('keydown', keyDownHandler);
});
}, [iframeKeyDownHandler]);

React.useEffect(() => {
if (!contentWindow) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export default function RenderOverlay({ canvasHostRef }: RenderOverlayProps) {
return [pageNode, ...appDom.getDescendants(dom, pageNode)];
}, [dom, pageNode]);

const selectedNode = selection && appDom.getNode(dom, selection);
const selectedNode = selection && appDom.getMaybeNode(dom, selection);

const overlayRef = React.useRef<HTMLDivElement | null>(null);

Expand Down
18 changes: 17 additions & 1 deletion packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import SplitPane from '../../../components/SplitPane';
import RenderPanel from './RenderPanel';
import ComponentPanel from './ComponentPanel';
import { PageEditorProvider } from './PageEditorProvider';
import { useDom } from '../../DomLoader';
import { useDom, useDomApi } from '../../DomLoader';
import * as appDom from '../../../appDom';
import ComponentCatalog from './ComponentCatalog';
import NotFoundEditor from '../NotFoundEditor';
import usePageTitle from '../../../utils/usePageTitle';
import useLocalStorageState from '../../../utils/useLocalStorageState';
import useDebouncedHandler from '../../../utils/useDebouncedHandler';
import useShortcut from '../../../utils/useShortcut';
import { hasFieldFocus } from '../../../utils/fields';

const classes = {
renderPanel: 'Toolpad_RenderPanel',
Expand Down Expand Up @@ -70,9 +72,23 @@ interface PageEditorProps {

export default function PageEditor({ appId }: PageEditorProps) {
const dom = useDom();
const domApi = useDomApi();
const { nodeId } = useParams();
const pageNode = appDom.getMaybeNode(dom, nodeId as NodeId, 'page');

useShortcut({ code: 'KeyZ', metaKey: true, preventDefault: false }, () => {
if (hasFieldFocus()) {
return;
}
domApi.undo();
});
useShortcut({ code: 'KeyZ', metaKey: true, shiftKey: true, preventDefault: false }, () => {
if (hasFieldFocus()) {
return;
}
domApi.redo();
});

return pageNode ? (
<PageEditorContent appId={appId} node={pageNode} />
) : (
Expand Down
109 changes: 108 additions & 1 deletion packages/toolpad-app/src/toolpad/DomLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { NodeId, BindableAttrValue, BindableAttrValues } from '@mui/toolpad-core';
import invariant from 'invariant';
import { throttle } from 'lodash-es';
import * as appDom from '../appDom';
import { update } from '../utils/immutability';
import client from '../api';
Expand All @@ -9,9 +10,19 @@ import useDebouncedHandler from '../utils/useDebouncedHandler';
import { createProvidedContext } from '../utils/react';
import { mapValues } from '../utils/collections';
import insecureHash from '../utils/insecureHash';
import useEvent from '../utils/useEvent';
import { NodeHashes } from '../types';

export type DomAction =
| {
type: 'DOM_UPDATE_HISTORY';
}
| {
type: 'DOM_UNDO';
}
| {
type: 'DOM_REDO';
}
| {
type: 'DOM_SAVING';
}
Expand Down Expand Up @@ -135,6 +146,8 @@ export function domReducer(dom: appDom.AppDom, action: DomAction): appDom.AppDom
}
}

const UNDO_HISTORY_LIMIT = 100;

export function domLoaderReducer(state: DomLoader, action: DomAction): DomLoader {
if (state.dom) {
const newDom = domReducer(state.dom, action);
Expand All @@ -147,6 +160,60 @@ export function domLoaderReducer(state: DomLoader, action: DomAction): DomLoader
}

switch (action.type) {
case 'DOM_UPDATE_HISTORY': {
const updatedUndoStack = [...state.undoStack, state.dom];

if (updatedUndoStack.length > UNDO_HISTORY_LIMIT) {
updatedUndoStack.shift();
}

return update(state, {
undoStack: updatedUndoStack,
redoStack: [],
});
}
case 'DOM_UNDO': {
const undoStack = [...state.undoStack];
const redoStack = [...state.redoStack];

if (undoStack.length < 2) {
return state;
}

const currentState = undoStack.pop();

const previousDom = undoStack[undoStack.length - 1];

if (!previousDom || !currentState) {
return state;
}

redoStack.push(currentState);

return update(state, {
dom: previousDom,
undoStack,
redoStack,
});
}
case 'DOM_REDO': {
const undoStack = [...state.undoStack];
const redoStack = [...state.redoStack];

const nextDom = redoStack.pop();

if (!nextDom) {
return state;
}

undoStack.push(nextDom);

return update(state, {
dom: nextDom,
undoStack,
redoStack,
});
}
case 'DOM_SAVING': {
return update(state, {
saving: true,
Expand Down Expand Up @@ -174,6 +241,12 @@ export function domLoaderReducer(state: DomLoader, action: DomAction): DomLoader

function createDomApi(dispatch: React.Dispatch<DomAction>) {
return {
undo() {
dispatch({ type: 'DOM_UNDO' });
},
redo() {
dispatch({ type: 'DOM_REDO' });
},
setNodeName(nodeId: NodeId, name: string) {
dispatch({ type: 'DOM_SET_NODE_NAME', nodeId, name });
},
Expand Down Expand Up @@ -276,6 +349,8 @@ export interface DomLoader {
saving: boolean;
unsavedChanges: number;
saveError: string | null;
undoStack: appDom.AppDom[];
redoStack: appDom.AppDom[];
}

export function getNodeHashes(dom: appDom.AppDom): NodeHashes {
Expand Down Expand Up @@ -319,6 +394,15 @@ export interface DomContextProps {
children?: React.ReactNode;
}

const SKIP_UNDO_ACTIONS = new Set([
'DOM_UPDATE_HISTORY',
'DOM_UNDO',
'DOM_REDO',
'DOM_SAVED',
'DOM_SAVING',
'DOM_SAVING_ERROR',
]);

export default function DomProvider({ appId, children }: DomContextProps) {
const { data: dom } = client.useQuery('loadDom', [appId], { suspense: true });

Expand All @@ -330,8 +414,31 @@ export default function DomProvider({ appId, children }: DomContextProps) {
saveError: null,
savedDom: dom,
dom,
undoStack: [dom],
redoStack: [],
});
const api = React.useMemo(() => createDomApi(dispatch), []);

const scheduleHistoryUpdate = React.useMemo(
() =>
throttle(
() => {
dispatch({ type: 'DOM_UPDATE_HISTORY' });
},
500,
{ leading: false, trailing: true },
),
[],
);

const dispatchWithHistory = useEvent((action: DomAction) => {
dispatch(action);

if (!SKIP_UNDO_ACTIONS.has(action.type)) {
scheduleHistoryUpdate();
}
});

const api = React.useMemo(() => createDomApi(dispatchWithHistory), [dispatchWithHistory]);

const handleSave = React.useCallback(() => {
if (!state.dom || state.saving || state.savedDom === state.dom) {
Expand Down
18 changes: 18 additions & 0 deletions packages/toolpad-app/src/utils/fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const SINGLE_ACTION_INPUT_TYPES = ['checkbox', 'radio', 'range', 'color'];

export const hasFieldFocus = (documentTarget = document) => {
const activeElement = documentTarget.activeElement as HTMLElement | HTMLInputElement;

if (!activeElement) {
return false;
}
const { nodeName, contentEditable } = activeElement;

const type = activeElement.getAttribute('type') || '';

const focusedInput = nodeName === 'INPUT' && !SINGLE_ACTION_INPUT_TYPES.includes(type);
const focusedTextarea = nodeName === 'TEXTAREA';
const focusedContentEditable = contentEditable === 'true';

return focusedInput || focusedTextarea || focusedContentEditable;
};
12 changes: 8 additions & 4 deletions packages/toolpad-app/src/utils/useShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import * as React from 'react';
export interface ShortCut {
code: string;
metaKey?: boolean;
shiftKey?: boolean;
disabled?: boolean;
preventDefault?: boolean;
}

export default function useShortcut(
{ code, metaKey = false, disabled = false }: ShortCut,
{ code, metaKey = false, disabled = false, shiftKey = false, preventDefault = true }: ShortCut,
handler: () => void,
) {
React.useEffect(() => {
Expand All @@ -16,13 +18,15 @@ export default function useShortcut(
}

const handleKeydown = (event: KeyboardEvent) => {
if (event.code === code && event.metaKey === metaKey) {
if (event.code === code && event.metaKey === metaKey && event.shiftKey === shiftKey) {
handler();
event.preventDefault();
if (preventDefault) {
event.preventDefault();
}
}
};

document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [code, metaKey, handler, disabled]);
}, [code, metaKey, shiftKey, handler, disabled, preventDefault]);
}

0 comments on commit 0c0b7f8

Please sign in to comment.