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:
parent
054669cfef
commit
d0365933a9
80
src/history.ts
Normal file
80
src/history.ts
Normal 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 };
|
||||||
|
};
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user