From 63e8a225ce22056d5bfbdec4ebd847c0bd82cba6 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Fri, 25 Mar 2022 17:54:26 +0000 Subject: [PATCH] undo-redo foundation + first use case with instance insertion #8 --- app/canvas/canvas.tsx | 10 +++- .../wrapper-component/use-draggable.ts | 7 ++- app/canvas/shared/tree.ts | 4 +- app/canvas/shared/undo-redo.ts | 49 +++++++++++++++++ app/canvas/shared/use-drag-drop-handlers.ts | 54 +++++++++---------- app/canvas/shared/use-shortcuts.ts | 19 ++++++- app/designer/features/sync/use-sync.ts | 4 +- package.json | 2 +- yarn.lock | 14 +++-- 9 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 app/canvas/shared/undo-redo.ts diff --git a/app/canvas/canvas.tsx b/app/canvas/canvas.tsx index c00d27054b..842d85350d 100644 --- a/app/canvas/canvas.tsx +++ b/app/canvas/canvas.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { DndProvider } from "react-dnd"; import { TouchBackend } from "react-dnd-touch-backend"; import { @@ -28,6 +28,7 @@ import { } from "./shared/publish"; import { useActiveElementTracking } from "./shared/active-element"; import { WrapperComponentDev } from "./features/wrapper-component"; +import { initUndoRedo } from "./shared/undo-redo"; const useElementsTree = ( rootInstance: Instance, @@ -68,6 +69,12 @@ const useIsPreviewMode = () => { return isPreviewMode; }; +const useUndoRedo = () => { + useEffect(() => { + initUndoRedo(); + }, []); +}; + const PreviewMode = ({ rootInstance }: { rootInstance: Instance }) => { return createElementsTree({ instance: rootInstance, @@ -100,6 +107,7 @@ const DesignMode = ({ usePublishRootInstance(rootInstance); useActiveElementTracking({ rootInstance }); useShortcuts({ rootInstance }); + useUndoRedo(); const elements = useElementsTree( rootInstance, diff --git a/app/canvas/features/wrapper-component/use-draggable.ts b/app/canvas/features/wrapper-component/use-draggable.ts index 1e08c44e16..89c680a271 100644 --- a/app/canvas/features/wrapper-component/use-draggable.ts +++ b/app/canvas/features/wrapper-component/use-draggable.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useDrag } from "react-dnd"; import { type Instance } from "@webstudio-is/sdk"; import { type DragData } from "~/shared/component"; @@ -11,6 +11,8 @@ type UseDraggable = { }; export const useDraggable = ({ instance, isDisabled }: UseDraggable) => { + const isDraggingRef = useRef(false); + //const updatePointerOutline = usePointerOutline(); const [{ isDragging, currentOffset }, dragRefCallback] = useDrag( () => ({ @@ -33,9 +35,12 @@ export const useDraggable = ({ instance, isDisabled }: UseDraggable) => { if (isDragging === true) { publish<"dragStartInstance">({ type: "dragStartInstance" }); } else { + // Initial state is false, don't fire event if there was no dragging + //if (isDraggingRef.current === false) return; // Ended publish<"dragEndInstance", Instance["id"]>({ type: "dragEndInstance" }); } + isDraggingRef.current = isDragging; }, [isDragging, instance.id]); useEffect(() => { diff --git a/app/canvas/shared/tree.ts b/app/canvas/shared/tree.ts index 13364fa0e9..955866e98d 100644 --- a/app/canvas/shared/tree.ts +++ b/app/canvas/shared/tree.ts @@ -90,8 +90,8 @@ export const useDeleteInstance = ({ setRootInstance: (instance: Instance) => void; }) => { const [, setSelectedInstance] = useSelectedInstance(); - useSubscribe<"deleteSelectedInstance", { id: Instance["id"] }>( - "deleteSelectedInstance", + useSubscribe<"deleteInstace", { id: Instance["id"] }>( + "deleteInstace", ({ id }) => { const newRootInstance = deleteInstance(rootInstance, id); setRootInstance(newRootInstance); diff --git a/app/canvas/shared/undo-redo.ts b/app/canvas/shared/undo-redo.ts new file mode 100644 index 0000000000..acbde3c134 --- /dev/null +++ b/app/canvas/shared/undo-redo.ts @@ -0,0 +1,49 @@ +import { emitter } from "@webstudio-is/sdk"; +import { publish } from "~/canvas/shared/pubsub"; + +const types = ["insertInstance"] as const; + +const maxLength = 100; + +type Action = { + type: typeof types[number]; + payload: unknown; +}; + +const current: Array = []; +const undone: Array = []; + +export const add = (action: Action) => { + current.push(action); + if (history.length >= maxLength) { + current.shift(); + } + // Undone items can't be redone if user adds another change to the current list. + // It's artificial, but that's how we know it. + undone.splice(0); +}; + +export const initUndoRedo = () => { + for (const type of types) { + emitter.on(type, (payload) => { + add({ type, payload }); + }); + } +}; + +export const undo = () => { + const action = current.pop(); + if (action === undefined) return; + undone.push(action); + publish({ + type: action.type + "Undo", + payload: action.payload, + }); +}; + +export const redo = () => { + const action = undone.pop(); + if (action === undefined) return; + current.push(action); + publish(action); +}; diff --git a/app/canvas/shared/use-drag-drop-handlers.ts b/app/canvas/shared/use-drag-drop-handlers.ts index f5a86cb03f..d67eb71f3a 100644 --- a/app/canvas/shared/use-drag-drop-handlers.ts +++ b/app/canvas/shared/use-drag-drop-handlers.ts @@ -39,25 +39,6 @@ export const useDragDropHandlers = ({ const [dropData, setDropData] = useDropData(); const [dragData, setDragData] = useState(); - const insert = ({ - instance, - dropData, - }: { - instance: Instance; - dropData: DropData; - }) => { - const instanceInsertionSpec = { - instance, - parentId: dropData.instance.id, - position: dropData.position, - } as const; - setInstanceInsertionSpec(instanceInsertionSpec); - publish<"syncInstanceInsertion", InstanceInsertionSpec>({ - type: "syncInstanceInsertion", - payload: instanceInsertionSpec, - }); - }; - useSubscribe<"dragStartInstance">("dragStartInstance", () => { setSelectedInstance(undefined); }); @@ -82,12 +63,13 @@ export const useDragDropHandlers = ({ findInstanceById(rootInstance, dragData.instance.id) === undefined; if (isNew) { - insert({ - instance: dragData.instance, - dropData, + publish<"insertInstance", Instance>({ + type: "insertInstance", + payload: dragData.instance, }); return; } + const instanceReparentingSpec = { parentId: dropData.instance.id, position: dropData.position, @@ -141,17 +123,29 @@ export const useDragDropHandlers = ({ }); useSubscribe<"insertInstance", Instance>("insertInstance", (instance) => { - if (selectedInstance === undefined) { - setSelectedInstance(rootInstance); - } - insert({ + const instanceInsertionSpec = { instance, - dropData: { - position: "end", - instance: selectedInstance ?? rootInstance, - }, + parentId: selectedInstance?.id ?? rootInstance.id, + position: dropData?.position || "end", + }; + setInstanceInsertionSpec(instanceInsertionSpec); + publish<"syncInstanceInsertion", InstanceInsertionSpec>({ + type: "syncInstanceInsertion", + payload: instanceInsertionSpec, }); }); + useSubscribe<"insertInstanceUndo", Instance>( + "insertInstanceUndo", + (instance) => { + publish<"deleteInstace", { id: Instance["id"] }>({ + type: "deleteInstace", + payload: { + id: instance.id, + }, + }); + } + ); + return { instanceInsertionSpec, instanceReparentingSpec }; }; diff --git a/app/canvas/shared/use-shortcuts.ts b/app/canvas/shared/use-shortcuts.ts index 592cb0b2c0..351b92528b 100644 --- a/app/canvas/shared/use-shortcuts.ts +++ b/app/canvas/shared/use-shortcuts.ts @@ -2,6 +2,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { type Instance } from "@webstudio-is/sdk"; import { useSelectedInstance } from "./nano-values"; import { publish } from "./pubsub"; +import { redo, undo } from "./undo-redo"; export const useShortcuts = ({ rootInstance }: { rootInstance: Instance }) => { const [instance, setSelectedInstance] = useSelectedInstance(); @@ -10,8 +11,8 @@ export const useShortcuts = ({ rootInstance }: { rootInstance: Instance }) => { () => { // @todo tell user they can't delete root if (instance === undefined || instance.id === rootInstance.id) return; - publish<"deleteSelectedInstance", { id: Instance["id"] }>({ - type: "deleteSelectedInstance", + publish<"deleteInstace", { id: Instance["id"] }>({ + type: "deleteInstace", payload: { id: instance.id, }, @@ -33,4 +34,18 @@ export const useShortcuts = ({ rootInstance }: { rootInstance: Instance }) => { }, [instance] ); + + useHotkeys( + // Undo + "cmd+z, ctrl+z", + undo, + [] + ); + + useHotkeys( + // Redo + "cmd+shift+z, ctrl+shift+z", + redo, + [] + ); }; diff --git a/app/designer/features/sync/use-sync.ts b/app/designer/features/sync/use-sync.ts index 1049a8aceb..b39b5758dc 100644 --- a/app/designer/features/sync/use-sync.ts +++ b/app/designer/features/sync/use-sync.ts @@ -77,8 +77,8 @@ export const useSync = ({ project }: { config: Config; project: Project }) => { } ); - useSubscribe<"deleteSelectedInstance", { id: Instance["id"] }>( - "deleteSelectedInstance", + useSubscribe<"deleteInstace", { id: Instance["id"] }>( + "deleteInstace", ({ id }) => { enqueue(() => fetch(`/rest/delete-instance/${project.devTreeId}`, { diff --git a/package.json b/package.json index 0f8c9e07d2..016cb4bbc4 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@remix-run/vercel": "^1.2.3", "@stitches/react": "^1.2.6", "@stitches/stringify": "^1.2.6", - "@webstudio-is/sdk": "^0.1.18", + "@webstudio-is/sdk": "^0.1.19", "bson-objectid": "^2.0.2", "camelcase": "^6.3.0", "hyphenate-style-name": "^1.0.4", diff --git a/yarn.lock b/yarn.lock index acc00f4606..e1526447d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1820,14 +1820,15 @@ dependencies: web-streams-polyfill "^3.1.1" -"@webstudio-is/sdk@^0.1.18": - version "0.1.18" - resolved "https://registry.yarnpkg.com/@webstudio-is/sdk/-/sdk-0.1.18.tgz#86fa61b508c0293f0ed9baca6e6b89585f44e320" - integrity sha512-rEgIG4qZMKhyudqVhQGlc7LB1ibMQ6KA9Jr+pjMTAhv3o/Jgqk4YA4eXyui8ZFZvC5ibIakdzqsv+4O6QTXGSQ== +"@webstudio-is/sdk@^0.1.19": + version "0.1.19" + resolved "https://registry.yarnpkg.com/@webstudio-is/sdk/-/sdk-0.1.19.tgz#6075c3095d6f145ee45f3830cbb40044a2f464c5" + integrity sha512-OnhDlhP9EneFCg5p96saEeRKiGdgcq/ngEq78YrVCQF15AvuLpDrCZ5/P9KLEhvusEteNK0SmUYRKqZ2+IQ5NQ== dependencies: "@prisma/client" "^3.10.0" "@stitches/react" "^1.2.7" immer "^9.0.12" + mitt "^3.0.0" prisma "^3.10.0" react-nano-state "^0.4.0" @@ -6067,6 +6068,11 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" +mitt@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd" + integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"