-
Notifications
You must be signed in to change notification settings - Fork 25
Add keyboard shortcut (Mod+Z / Mod+Y) for undo-redo in drawing section #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,57 @@ export default class DrawingElementComponent extends React.Component<Props> { | |
| 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; | ||
ashu6783 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) => { | ||
ashu6783 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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,31 +197,38 @@ export default class DrawingElementComponent extends React.Component<Props> { | |
|
|
||
| componentDidMount() { | ||
| this.componentDidUpdate(); | ||
| window.addEventListener('keydown', this.handleKeyDown, true); | ||
| }; | ||
|
|
||
| componentWillUnmount() { | ||
| window.removeEventListener('keydown', this.handleKeyDown); | ||
| } | ||
|
|
||
|
|
||
| componentDidUpdate(prevProps?: Readonly<Props>) { | ||
| const { element, noteAssets } = this.props; | ||
|
|
||
| this.ongoingTouches = new OngoingTouches(); | ||
|
|
||
| const canvasElement = this.canvasElement; | ||
| if (!!canvasElement) { | ||
| setTimeout(() => this.initCanvas(), 0); | ||
|
|
||
| // setTimeout(() => this.initCanvas(), 0); | ||
| this.initCanvas(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: This was intentionally in a 0ms |
||
| 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<Props> { | |
| } | ||
|
|
||
| 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<Props> { | |
| this.ctx.lineTo(pos.x, pos.y); | ||
|
|
||
| this.ongoingTouches.deleteTouch(event.pointerId); | ||
| this.pushUndo(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. todo: indentation |
||
| }; | ||
|
|
||
| canvasElement.onpointercancel = event => { | ||
|
|
@@ -373,4 +433,4 @@ class OngoingTouches { | |
| deleteTouch(id: number) { | ||
| this.touches[id] = null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EditorState, SliceCaseReducers<EditorState>, '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: [], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion (non-blocking): These are all related (as is |
||
| past: [], | ||
| future: [], | ||
| }, | ||
| reducers: {}, | ||
| extraReducers: builder => builder | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Reducers must be immutable, so directly mutating |
||
| .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; | ||
| }), | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/[email protected]": | ||
| 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" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: This action doesn't look like it is being dispatched anywhere? Was this part of an earlier approach that has since been replaced by the logic in the component?