From 022fabc45bccfd1a6d4878b4a6b2d51ad75e7ac6 Mon Sep 17 00:00:00 2001 From: ashutosh gaurav Date: Sun, 5 Oct 2025 17:54:40 +0530 Subject: [PATCH] Add keyboard shortcut (Mod+Z / Mod+Y) for undo-redo in drawing section --- app/package.json | 8 +- app/src/app/actions.ts | 3 + .../drawing/DrawingElementComponent.tsx | 88 ++++++++++++++++--- app/src/app/reducers/EditorReducer.ts | 88 ++++++++++++------- app/src/app/reducers/NoteReducer.ts | 3 +- app/src/app/services/shortcuts.ts | 10 +++ app/yarn.lock | 13 +++ 7 files changed, 161 insertions(+), 52 deletions(-) diff --git a/app/package.json b/app/package.json index 9113af12..9900f392 100644 --- a/app/package.json +++ b/app/package.json @@ -5,7 +5,7 @@ "scripts": { "preinstall": "python3 ../libs/build-libs.py && ./get_precache_files.py > src/extraPrecacheFiles.ts", "prestart": "tsgo -p _build/tsconfig.json", - "start": "NODE_ENV='' node --experimental-import-meta-resolve _build/build.mjs", + "start": "cross-env NODE_ENV='' node --experimental-import-meta-resolve _build/build.mjs", "prebuild": "tsgo -p _build/tsconfig.json", "build": "NODE_ENV=production node --experimental-import-meta-resolve _build/build.mjs", "test": "TZ=NZ jest", @@ -13,6 +13,7 @@ "typecheck:watch": "tsgo --noEmit --watch -p ./tsconfig.json", "lint": "eslint --flag unstable_ts_config src/", "lint:fix": "eslint --flag unstable_ts_config --fix src/" + }, "engines": { "node": ">=17.1.0" @@ -101,8 +102,10 @@ "@types/react-dom": "^17.0.25", "@types/semver": "^7.5.8", "@types/showdown": "^1.9.4", + "@typescript/native-preview": "7.0.0-dev.20250809.1", "babel-eslint": "^10.1.0", "browserslist": "^4.23.3", + "cross-env": "^10.1.0", "esbuild": "~0.16.17", "esbuild-plugin-browserslist": "~0.6.0", "esbuild-plugin-manifest": "~0.5.0", @@ -123,8 +126,7 @@ "servor": "^4.0.2", "ts-jest": "^27.1.5", "typescript-eslint": "^8.1.0", - "workbox-build": "^6.6.1", - "@typescript/native-preview": "7.0.0-dev.20250809.1" + "workbox-build": "^6.6.1" }, "resolutions": { "@types/react": "^17", diff --git a/app/src/app/actions.ts b/app/src/app/actions.ts index a70dbe86..bf20953f 100644 --- a/app/src/app/actions.ts +++ b/app/src/app/actions.ts @@ -95,6 +95,9 @@ export const actions = { collapseAllExplorer: actionCreator('COLLAPSE_ALL_EXPLORER'), openEditor: actionCreator('OPEN_EDITOR'), updateElement: actionCreator('UPDATE_ELEMENT'), + addDrawing: actionCreator('ADD_DRAWING'), + undoDrawing: actionCreator('UNDO_DRAWING'), + redoDrawing: actionCreator('REDO_DRAWING'), updateDefaultFontSize: actionCreator('UPDATE_DEFAULT_FONT_SIZE'), newSection: actionCreator('NEW_SECTION'), newNote: actionCreator('NEW_NOTE'), diff --git a/app/src/app/components/note-viewer/elements/drawing/DrawingElementComponent.tsx b/app/src/app/components/note-viewer/elements/drawing/DrawingElementComponent.tsx index 2c3d3e63..8f7b013d 100644 --- a/app/src/app/components/note-viewer/elements/drawing/DrawingElementComponent.tsx +++ b/app/src/app/components/note-viewer/elements/drawing/DrawingElementComponent.tsx @@ -41,6 +41,57 @@ export default class DrawingElementComponent extends React.Component { private canvasImage?: Blob | null; private rainbowIndex = 0; + private undoStack: string[] = []; + private redoStack: string[] = []; + private maxHistory = 50; + +private pushUndo = () => { + if (!this.canvasElement) return; + const snapshot = this.canvasElement.toDataURL(); + if (this.undoStack.length === 0 || this.undoStack[this.undoStack.length - 1] !== snapshot) { + this.undoStack.push(snapshot); + if (this.undoStack.length > this.maxHistory) this.undoStack.shift(); + } + this.redoStack = []; +}; + + +private undo = () => { + if (this.undoStack.length < 2 || !this.canvasElement) return; + const current = this.undoStack.pop(); // discard current state + this.redoStack.push(current!); + const prev = this.undoStack[this.undoStack.length - 1]; + this.restoreCanvas(prev); + }; + + private redo = () => { + if (this.redoStack.length === 0 || !this.canvasElement) return; + const snapshot = this.redoStack.pop()!; + this.undoStack.push(snapshot); + this.restoreCanvas(snapshot); + }; + + private restoreCanvas = (snapshot: string) => { + if (!this.canvasElement) return; + const img = new Image(); + img.onload = () => { + this.ctx.clearRect(0, 0, this.canvasElement!.width, this.canvasElement!.height); + this.ctx.drawImage(img, 0, 0); + }; + img.src = snapshot; + } + + private handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + this.undo(); + } else if (e.ctrlKey && e.key.toLowerCase() === 'y') { + e.preventDefault(); + this.redo(); + } + }; + + render() { const { element, elementEditing, theme } = this.props; @@ -146,8 +197,14 @@ export default class DrawingElementComponent extends React.Component { componentDidMount() { this.componentDidUpdate(); + window.addEventListener('keydown', this.handleKeyDown, true); + }; + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleKeyDown); } + componentDidUpdate(prevProps?: Readonly) { const { element, noteAssets } = this.props; @@ -155,22 +212,23 @@ export default class DrawingElementComponent extends React.Component { const canvasElement = this.canvasElement; if (!!canvasElement) { - setTimeout(() => this.initCanvas(), 0); + + // setTimeout(() => this.initCanvas(), 0); + this.initCanvas(); const isNewEditor = this.props.elementEditing !== prevProps?.elementEditing; if (isNewEditor) { - // Restore saved image to canvas - const img: HTMLImageElement = new Image(); - img.onload = () => { - const canvasElement = this.canvasElement; - if (!canvasElement) return; - canvasElement.width = img.naturalWidth; - canvasElement.height = img.naturalHeight; - this.ctx.drawImage(img, 0, 0); - }; - img.src = noteAssets[element.args.ext!]; - } - return; + const img: HTMLImageElement = new Image(); + img.onload = () => { + const canvasElement = this.canvasElement; + if (!canvasElement) return; + canvasElement.width = img.naturalWidth; + canvasElement.height = img.naturalHeight; + this.ctx.drawImage(img, 0, 0); + }; + img.src = noteAssets[element.args.ext!]; } + return; + } let img: HTMLImageElement = new Image(); img.onload = () => { @@ -215,6 +273,7 @@ export default class DrawingElementComponent extends React.Component { } this.ctx = canvasElement.getContext('2d')!; + this.pushUndo(); canvasElement.onpointerdown = event => { this.ongoingTouches.setTouch(event); @@ -267,6 +326,7 @@ export default class DrawingElementComponent extends React.Component { this.ctx.lineTo(pos.x, pos.y); this.ongoingTouches.deleteTouch(event.pointerId); + this.pushUndo(); }; canvasElement.onpointercancel = event => { @@ -373,4 +433,4 @@ class OngoingTouches { deleteTouch(id: number) { this.touches[id] = null; } -} +} \ No newline at end of file diff --git a/app/src/app/reducers/EditorReducer.ts b/app/src/app/reducers/EditorReducer.ts index 72b89081..5ae7f8c6 100644 --- a/app/src/app/reducers/EditorReducer.ts +++ b/app/src/app/reducers/EditorReducer.ts @@ -1,41 +1,63 @@ import { actions } from '../actions'; import { createSlice, SliceCaseReducers } from '@reduxjs/toolkit'; -export type EditorState = { - shouldSpellCheck: boolean, - shouldWordWrap: boolean, - drawMode: DrawMode, - drawingLineColour: string -}; - export const enum DrawMode { - Line = 'line', - ERASE = 'erase', - RAINBOW = 'rainbow' + Line = 'line', + ERASE = 'erase', + RAINBOW = 'rainbow', } +export type EditorState = { + shouldSpellCheck: boolean; + shouldWordWrap: boolean; + drawMode: DrawMode; + drawingLineColour: string; + drawings: any[]; + past: any[]; + future: any[]; +}; + export const editorSlice = createSlice, 'editor'>({ - name: 'editor', - initialState: { - shouldSpellCheck: true, - shouldWordWrap: true, - drawMode: DrawMode.Line, - drawingLineColour: '#000000' - }, - reducers: {}, - extraReducers: builder => builder - .addCase(actions.toggleSpellCheck, (state, action) => ({ - ...state, - shouldSpellCheck: action.payload ?? !state.shouldSpellCheck - })) - .addCase(actions.toggleWordWrap, (state, action) => ({ - ...state, - shouldWordWrap: action.payload ?? !state.shouldWordWrap - })) - .addCase(actions.setDrawMode, (state, { payload }) => ({ ...state, drawMode: payload })) - .addCase(actions.setDrawingLineColour, (state, { payload }) => ({ - ...state, - drawingLineColour: payload, - drawMode: DrawMode.Line - })) + name: 'editor', + initialState: { + shouldSpellCheck: true, + shouldWordWrap: true, + drawMode: DrawMode.Line, + drawingLineColour: '#000000', + drawings: [], + past: [], + future: [], + }, + reducers: {}, + extraReducers: builder => builder + .addCase(actions.toggleSpellCheck, (state, action) => { + state.shouldSpellCheck = action.payload ?? !state.shouldSpellCheck; + }) + .addCase(actions.toggleWordWrap, (state, action) => { + state.shouldWordWrap = action.payload ?? !state.shouldWordWrap; + }) + .addCase(actions.setDrawMode, (state, { payload }) => { + state.drawMode = payload; + }) + .addCase(actions.setDrawingLineColour, (state, { payload }) => { + state.drawingLineColour = payload; + state.drawMode = DrawMode.Line; + }) + .addCase(actions.addDrawing, (state, { payload }) => { + state.past.push([...state.drawings]); // push current drawings to past + state.future = []; + state.drawings.push(payload); + }) + .addCase(actions.undoDrawing, (state) => { + if (state.past.length === 0) return; + const previous = state.past.pop()!; + state.future.unshift([...state.drawings]); + state.drawings = previous; + }) + .addCase(actions.redoDrawing, (state) => { + if (state.future.length === 0) return; + const next = state.future.shift()!; + state.past.push([...state.drawings]); + state.drawings = next; + }), }); diff --git a/app/src/app/reducers/NoteReducer.ts b/app/src/app/reducers/NoteReducer.ts index 1258799a..09efc951 100644 --- a/app/src/app/reducers/NoteReducer.ts +++ b/app/src/app/reducers/NoteReducer.ts @@ -29,7 +29,7 @@ export class NoteReducer extends AbstractReducer { x: 0, y: 0, enabled: false - } + }, }; public reducer(state: ICurrentNoteState | undefined, action: Action): ICurrentNoteState { @@ -95,7 +95,6 @@ export class NoteReducer extends AbstractReducer { insertElement: { ...state.insertElement, enabled: false } } } - return state; } diff --git a/app/src/app/services/shortcuts.ts b/app/src/app/services/shortcuts.ts index d1214750..9ad252af 100644 --- a/app/src/app/services/shortcuts.ts +++ b/app/src/app/services/shortcuts.ts @@ -76,4 +76,14 @@ export function enableKeyboardShortcuts(store: Store { + e.preventDefault(); + store.dispatch(actions.undoDrawing()); + }); + + mousetrap.bind('mod+y', e => { + e.preventDefault(); + store.dispatch(actions.redoDrawing()); + }); } diff --git a/app/yarn.lock b/app/yarn.lock index 1d8d14a0..ccea31a0 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1029,6 +1029,11 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@esbuild/android-arm64@0.16.17": version "0.16.17" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz#cf91e86df127aa3d141744edafcba0abdc577d23" @@ -3109,6 +3114,14 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +cross-env@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"