Extract history (#213)

* Extract History into its own module

* Encapsulate undo and redo actions within history

* Encapsulate clearing redo stack within History

* Add private access modifiers to scene history class member variables

* Remove duplicate files
This commit is contained in:
Gasim Gasimzada 2020-01-06 21:58:48 +04:00 committed by GitHub
parent 054669cfef
commit d0365933a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 92 additions and 90 deletions

80
src/history.ts Normal file
View File

@ -0,0 +1,80 @@
import { ExcalidrawElement } from "./element/types";
import { generateDraw } from "./element";
class SceneHistory {
private recording: boolean = true;
private stateHistory: string[] = [];
private redoStack: string[] = [];
generateCurrentEntry(elements: ExcalidrawElement[]) {
return JSON.stringify(
elements.map(element => ({ ...element, isSelected: false }))
);
}
pushEntry(newEntry: string) {
if (
this.stateHistory.length > 0 &&
this.stateHistory[this.stateHistory.length - 1] === newEntry
) {
// If the last entry is the same as this one, ignore it
return;
}
this.stateHistory.push(newEntry);
}
restoreEntry(elements: ExcalidrawElement[], entry: string) {
const newElements = JSON.parse(entry);
elements.splice(0, elements.length);
newElements.forEach((newElement: ExcalidrawElement) => {
generateDraw(newElement);
elements.push(newElement);
});
// When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back
this.skipRecording();
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(elements: ExcalidrawElement[]) {
const currentEntry = this.generateCurrentEntry(elements);
const entryToRestore = this.redoStack.pop();
if (entryToRestore !== undefined) {
this.restoreEntry(elements, entryToRestore);
this.stateHistory.push(currentEntry);
}
}
undoOnce(elements: ExcalidrawElement[]) {
const currentEntry = this.generateCurrentEntry(elements);
let entryToRestore = this.stateHistory.pop();
// If nothing was changed since last, take the previous one
if (currentEntry === entryToRestore) {
entryToRestore = this.stateHistory.pop();
}
if (entryToRestore !== undefined) {
this.restoreEntry(elements, entryToRestore);
this.redoStack.push(currentEntry);
}
}
isRecording() {
return this.recording;
}
skipRecording() {
this.recording = false;
}
resumeRecording() {
this.recording = true;
}
}
export const createHistory: () => { history: SceneHistory } = () => {
const history = new SceneHistory();
return { history };
};

View File

@ -33,43 +33,15 @@ import { EditableText } from "./components/EditableText";
import { ButtonSelect } from "./components/ButtonSelect"; import { ButtonSelect } from "./components/ButtonSelect";
import { ColorPicker } from "./components/ColorPicker"; import { ColorPicker } from "./components/ColorPicker";
import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes"; import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes";
import { createHistory } from "./history";
import "./styles.scss"; import "./styles.scss";
const { elements } = createScene(); const { elements } = createScene();
const { history } = createHistory();
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
let skipHistory = false;
const stateHistory: string[] = [];
const redoStack: string[] = [];
function generateHistoryCurrentEntry() {
return JSON.stringify(
elements.map(element => ({ ...element, isSelected: false }))
);
}
function pushHistoryEntry(newEntry: string) {
if (
stateHistory.length > 0 &&
stateHistory[stateHistory.length - 1] === newEntry
) {
// If the last entry is the same as this one, ignore it
return;
}
stateHistory.push(newEntry);
}
function restoreHistoryEntry(entry: string) {
const newElements = JSON.parse(entry);
elements.splice(0, elements.length);
newElements.forEach((newElement: ExcalidrawElement) => {
generateDraw(newElement);
elements.push(newElement);
});
// When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back
skipHistory = true;
}
const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_LEFT = 250;
const CANVAS_WINDOW_OFFSET_TOP = 0; const CANVAS_WINDOW_OFFSET_TOP = 0;
@ -231,25 +203,12 @@ class App extends React.Component<{}, AppState> {
} else if (shapesShortcutKeys.includes(event.key.toLowerCase())) { } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) {
this.setState({ elementType: findShapeByKey(event.key) }); this.setState({ elementType: findShapeByKey(event.key) });
} else if (event.metaKey && event.code === "KeyZ") { } else if (event.metaKey && event.code === "KeyZ") {
const currentEntry = generateHistoryCurrentEntry();
if (event.shiftKey) { if (event.shiftKey) {
// Redo action // Redo action
const entryToRestore = redoStack.pop(); history.redoOnce(elements);
if (entryToRestore !== undefined) {
restoreHistoryEntry(entryToRestore);
stateHistory.push(currentEntry);
}
} else { } else {
// undo action // undo action
let lastEntry = stateHistory.pop(); history.undoOnce(elements);
// If nothing was changed since last, take the previous one
if (currentEntry === lastEntry) {
lastEntry = stateHistory.pop();
}
if (lastEntry !== undefined) {
restoreHistoryEntry(lastEntry);
redoStack.push(currentEntry);
}
} }
this.forceUpdate(); this.forceUpdate();
event.preventDefault(); event.preventDefault();
@ -798,7 +757,7 @@ class App extends React.Component<{}, AppState> {
lastX = x; lastX = x;
lastY = y; lastY = y;
// We don't want to save history when resizing an element // We don't want to save history when resizing an element
skipHistory = true; history.skipRecording();
this.forceUpdate(); this.forceUpdate();
return; return;
} }
@ -818,7 +777,7 @@ class App extends React.Component<{}, AppState> {
lastX = x; lastX = x;
lastY = y; lastY = y;
// We don't want to save history when dragging an element to initially size it // We don't want to save history when dragging an element to initially size it
skipHistory = true; history.skipRecording();
this.forceUpdate(); this.forceUpdate();
return; return;
} }
@ -850,7 +809,7 @@ class App extends React.Component<{}, AppState> {
setSelection(elements, draggingElement); setSelection(elements, draggingElement);
} }
// We don't want to save history when moving an element // We don't want to save history when moving an element
skipHistory = true; history.skipRecording();
this.forceUpdate(); this.forceUpdate();
}; };
@ -892,7 +851,7 @@ class App extends React.Component<{}, AppState> {
window.addEventListener("mouseup", onMouseUp); window.addEventListener("mouseup", onMouseUp);
// We don't want to save history on mouseDown, only on mouseUp when it's fully configured // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
skipHistory = true; history.skipRecording();
this.forceUpdate(); this.forceUpdate();
}} }}
onDoubleClick={e => { onDoubleClick={e => {
@ -952,11 +911,11 @@ class App extends React.Component<{}, AppState> {
viewBackgroundColor: this.state.viewBackgroundColor viewBackgroundColor: this.state.viewBackgroundColor
}); });
saveToLocalStorage(elements, this.state); saveToLocalStorage(elements, this.state);
if (!skipHistory) { if (history.isRecording()) {
pushHistoryEntry(generateHistoryCurrentEntry()); history.pushEntry(history.generateCurrentEntry(elements));
redoStack.splice(0, redoStack.length); history.clearRedoStack();
} }
skipHistory = false; history.resumeRecording();
} }
} }

View File

@ -1,37 +0,0 @@
/**
* https://stackoverflow.com/a/3368118
* Draws a rounded rectangle using the current state of the canvas.
* @param {CanvasRenderingContext2D} context
* @param {Number} x The top left x coordinate
* @param {Number} y The top left y coordinate
* @param {Number} width The width of the rectangle
* @param {Number} height The height of the rectangle
* @param {Number} radius The corner radius
*/
export function roundRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(
x + width,
y + height,
x + width - radius,
y + height
);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
context.fill();
context.stroke();
}