From fa1f6fe18fd0b73696aaa68b14f30cfcaf2d54a5 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 4 Feb 2021 09:11:27 +1100 Subject: [PATCH] Converted draw actions storage to collapsed representation Moved to command pattern for action application --- package.json | 1 + src/actions/Action.js | 59 +++++++++++++++++ src/actions/AddShapeAction.js | 15 +++++ src/actions/CutShapeAction.js | 40 ++++++++++++ src/actions/EditShapeAction.js | 17 +++++ src/actions/RemoveShapeAction.js | 13 ++++ src/actions/SubtractShapeAction.js | 32 ++++++++++ src/actions/index.js | 50 +++++++++++++++ src/components/map/Map.js | 60 +++++++----------- src/components/map/MapTiles.js | 4 +- src/contexts/MapDataContext.js | 8 +-- src/database.js | 17 ++++- src/helpers/diff.js | 8 ++- src/helpers/drawing.js | 79 +---------------------- src/network/NetworkedMapAndTokens.js | 95 +++++++++++++++++++++++----- yarn.lock | 5 ++ 16 files changed, 366 insertions(+), 137 deletions(-) create mode 100644 src/actions/Action.js create mode 100644 src/actions/AddShapeAction.js create mode 100644 src/actions/CutShapeAction.js create mode 100644 src/actions/EditShapeAction.js create mode 100644 src/actions/RemoveShapeAction.js create mode 100644 src/actions/SubtractShapeAction.js create mode 100644 src/actions/index.js diff --git a/package.json b/package.json index 98ef32b..cc774dc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "fuse.js": "^6.4.1", "interactjs": "^1.9.7", "konva": "^7.1.8", + "lodash.clonedeep": "^4.5.0", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", "normalize-wheel": "^1.0.1", diff --git a/src/actions/Action.js b/src/actions/Action.js new file mode 100644 index 0000000..3d52f4c --- /dev/null +++ b/src/actions/Action.js @@ -0,0 +1,59 @@ +import { Diff } from "deep-diff"; + +import { diff, revertChanges } from "../helpers/diff"; +import cloneDeep from "lodash.clonedeep"; + +/** + * @callback ActionUpdate + * @param {any} state + */ + +/** + * Implementation of the Command Pattern + * Wraps an update function with internal state to support undo + */ +class Action { + /** + * The update function called with the current state and should return the updated state + * This is implemented in the child class + * + * @type {ActionUpdate} + */ + update; + + /** + * The changes caused by the last state update + * @type {Diff} + */ + changes; + + /** + * Executes the action update on the state + * @param {any} state The current state to update + * @returns {any} The updated state + */ + execute(state) { + if (state && this.update) { + let newState = this.update(cloneDeep(state)); + this.changes = diff(state, newState); + return newState; + } + return state; + } + + /** + * Reverts the changes caused by the last call of `execute` + * @param {any} state The current state to perform the undo on + * @returns {any} The state with the last changes reverted + */ + undo(state) { + if (state && this.changes) { + let revertedState = cloneDeep(state); + revertChanges(revertedState, this.changes); + return revertedState; + } + return state; + } +} + +export default Action; diff --git a/src/actions/AddShapeAction.js b/src/actions/AddShapeAction.js new file mode 100644 index 0000000..5147d05 --- /dev/null +++ b/src/actions/AddShapeAction.js @@ -0,0 +1,15 @@ +import Action from "./Action"; + +class AddShapeAction extends Action { + constructor(shapes) { + super(); + this.update = (shapesById) => { + for (let shape of shapes) { + shapesById[shape.id] = shape; + } + return shapesById; + }; + } +} + +export default AddShapeAction; diff --git a/src/actions/CutShapeAction.js b/src/actions/CutShapeAction.js new file mode 100644 index 0000000..d6ccf56 --- /dev/null +++ b/src/actions/CutShapeAction.js @@ -0,0 +1,40 @@ +import polygonClipping from "polygon-clipping"; + +import Action from "./Action"; +import { + addPolygonDifferenceToShapes, + addPolygonIntersectionToShapes, +} from "../helpers/drawing"; + +class CutShapeAction extends Action { + constructor(shapes) { + super(); + this.update = (shapesById) => { + const actionGeom = shapes.map((actionShape) => [ + actionShape.data.points.map(({ x, y }) => [x, y]), + ]); + let cutShapes = {}; + for (let shape of Object.values(shapesById)) { + const shapePoints = shape.data.points.map(({ x, y }) => [x, y]); + const shapeHoles = shape.data.holes.map((hole) => + hole.map(({ x, y }) => [x, y]) + ); + let shapeGeom = [[shapePoints, ...shapeHoles]]; + try { + const difference = polygonClipping.difference(shapeGeom, actionGeom); + const intersection = polygonClipping.intersection( + shapeGeom, + actionGeom + ); + addPolygonDifferenceToShapes(shape, difference, cutShapes); + addPolygonIntersectionToShapes(shape, intersection, cutShapes); + } catch { + console.error("Unable to find intersection for shapes"); + } + } + return cutShapes; + }; + } +} + +export default CutShapeAction; diff --git a/src/actions/EditShapeAction.js b/src/actions/EditShapeAction.js new file mode 100644 index 0000000..e531df5 --- /dev/null +++ b/src/actions/EditShapeAction.js @@ -0,0 +1,17 @@ +import Action from "./Action"; + +class EditShapeAction extends Action { + constructor(shapes) { + super(); + this.update = (shapesById) => { + for (let edit of shapes) { + if (edit.id in shapesById) { + shapesById[edit.id] = { ...shapesById[edit.id], ...edit }; + } + } + return shapesById; + }; + } +} + +export default EditShapeAction; diff --git a/src/actions/RemoveShapeAction.js b/src/actions/RemoveShapeAction.js new file mode 100644 index 0000000..baa2df0 --- /dev/null +++ b/src/actions/RemoveShapeAction.js @@ -0,0 +1,13 @@ +import Action from "./Action"; +import { omit } from "../helpers/shared"; + +class RemoveShapeAction extends Action { + constructor(shapeIds) { + super(); + this.update = (shapesById) => { + return omit(shapesById, shapeIds); + }; + } +} + +export default RemoveShapeAction; diff --git a/src/actions/SubtractShapeAction.js b/src/actions/SubtractShapeAction.js new file mode 100644 index 0000000..6a1aece --- /dev/null +++ b/src/actions/SubtractShapeAction.js @@ -0,0 +1,32 @@ +import polygonClipping from "polygon-clipping"; + +import Action from "./Action"; +import { addPolygonDifferenceToShapes } from "../helpers/drawing"; + +class SubtractShapeAction extends Action { + constructor(shapes) { + super(); + this.update = (shapesById) => { + const actionGeom = shapes.map((actionShape) => [ + actionShape.data.points.map(({ x, y }) => [x, y]), + ]); + let subtractedShapes = {}; + for (let shape of Object.values(shapesById)) { + const shapePoints = shape.data.points.map(({ x, y }) => [x, y]); + const shapeHoles = shape.data.holes.map((hole) => + hole.map(({ x, y }) => [x, y]) + ); + let shapeGeom = [[shapePoints, ...shapeHoles]]; + try { + const difference = polygonClipping.difference(shapeGeom, actionGeom); + addPolygonDifferenceToShapes(shape, difference, subtractedShapes); + } catch { + console.error("Unable to find difference for shapes"); + } + } + return subtractedShapes; + }; + } +} + +export default SubtractShapeAction; diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 0000000..7f3489d --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,50 @@ +import AddShapeAction from "./AddShapeAction"; +import CutShapeAction from "./CutShapeAction"; +import EditShapeAction from "./EditShapeAction"; +import RemoveShapeAction from "./RemoveShapeAction"; +import SubtractShapeAction from "./SubtractShapeAction"; + +/** + * Convert from the previous representation of actions (1.7.0) to the new representation (1.8.0) + * and combine into shapes + * @param {Array} actions + * @param {number} actionIndex + */ +export function convertOldActionsToShapes(actions, actionIndex) { + let newShapes = {}; + for (let i = 0; i <= actionIndex; i++) { + const action = actions[i]; + if (!action) { + continue; + } + let newAction; + if (action.shapes) { + if (action.type === "add") { + newAction = new AddShapeAction(action.shapes); + } else if (action.type === "edit") { + newAction = new EditShapeAction(action.shapes); + } else if (action.type === "remove") { + newAction = new RemoveShapeAction(action.shapes); + } else if (action.type === "subtract") { + newAction = new SubtractShapeAction(action.shapes); + } else if (action.type === "cut") { + newAction = new CutShapeAction(action.shapes); + } + } else if (action.type === "remove" && action.shapeIds) { + newAction = new RemoveShapeAction(action.shapeIds); + } + + if (newAction) { + newShapes = newAction.execute(newShapes); + } + } + return newShapes; +} + +export { + AddShapeAction, + CutShapeAction, + EditShapeAction, + RemoveShapeAction, + SubtractShapeAction, +}; diff --git a/src/components/map/Map.js b/src/components/map/Map.js index 975313e..edae871 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -1,4 +1,4 @@ -import React, { useState, useContext, useEffect } from "react"; +import React, { useState, useContext } from "react"; import { Group } from "react-konva"; import MapControls from "./MapControls"; @@ -19,11 +19,17 @@ import TokenDragOverlay from "../token/TokenDragOverlay"; import NoteMenu from "../note/NoteMenu"; import NoteDragOverlay from "../note/NoteDragOverlay"; -import { drawActionsToShapes } from "../../helpers/drawing"; +import { + AddShapeAction, + CutShapeAction, + EditShapeAction, + RemoveShapeAction, +} from "../../actions"; function Map({ map, mapState, + mapActions, onMapTokenStateChange, onMapTokenStateRemove, onMapChange, @@ -67,13 +73,12 @@ function Map({ })); } + const drawShapes = Object.values(mapState?.drawShapes || {}); + const fogShapes = Object.values(mapState?.fogShapes || {}); + function handleToolAction(action) { if (action === "eraseAll") { - onMapDraw({ - type: "remove", - shapeIds: mapShapes.map((s) => s.id), - timestamp: Date.now(), - }); + onMapDraw(new RemoveShapeAction(drawShapes.map((s) => s.id))); } if (action === "mapUndo") { onMapDrawUndo(); @@ -89,47 +94,30 @@ function Map({ } } - const [mapShapes, setMapShapes] = useState([]); - function handleMapShapeAdd(shape) { - onMapDraw({ type: "add", shapes: [shape] }); + onMapDraw(new AddShapeAction([shape])); } function handleMapShapesRemove(shapeIds) { - onMapDraw({ type: "remove", shapeIds }); + onMapDraw(new RemoveShapeAction(shapeIds)); } - const [fogShapes, setFogShapes] = useState([]); - function handleFogShapeAdd(shape) { - onFogDraw({ type: "add", shapes: [shape] }); + onFogDraw(new AddShapeAction([shape])); } function handleFogShapeCut(shape) { - onFogDraw({ type: "cut", shapes: [shape] }); + onFogDraw(new CutShapeAction([shape])); } function handleFogShapesRemove(shapeIds) { - onFogDraw({ type: "remove", shapeIds }); + onFogDraw(new RemoveShapeAction(shapeIds)); } function handleFogShapesEdit(shapes) { - onFogDraw({ type: "edit", shapes }); + onFogDraw(new EditShapeAction(shapes)); } - // Replay the draw actions and convert them to shapes for the map drawing - useEffect(() => { - if (!mapState) { - return; - } - setMapShapes( - drawActionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex) - ); - setFogShapes( - drawActionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex) - ); - }, [mapState]); - const disabledControls = []; if (!allowMapDrawing) { disabledControls.push("drawing"); @@ -150,24 +138,24 @@ function Map({ } const disabledSettings = { fog: [], drawing: [] }; - if (mapShapes.length === 0) { + if (drawShapes.length === 0) { disabledSettings.drawing.push("erase"); } - if (!mapState || mapState.mapDrawActionIndex < 0) { + if (!mapState || mapActions.mapDrawActionIndex < 0) { disabledSettings.drawing.push("undo"); } if ( !mapState || - mapState.mapDrawActionIndex === mapState.mapDrawActions.length - 1 + mapActions.mapDrawActionIndex === mapActions.mapDrawActions.length - 1 ) { disabledSettings.drawing.push("redo"); } - if (!mapState || mapState.fogDrawActionIndex < 0) { + if (!mapState || mapActions.fogDrawActionIndex < 0) { disabledSettings.fog.push("undo"); } if ( !mapState || - mapState.fogDrawActionIndex === mapState.fogDrawActions.length - 1 + mapActions.fogDrawActionIndex === mapActions.fogDrawActions.length - 1 ) { disabledSettings.fog.push("redo"); } @@ -313,7 +301,7 @@ function Map({ const mapDrawing = ( 0 || - state.mapDrawActions.length > 0 || - state.fogDrawActions.length > 0 || + Object.values(state.drawShapes).length > 0 || + Object.values(state.fogShapes).length > 0 || Object.values(state.notes).length > 0 ) { hasMapState = true; diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js index 60e8d9d..5783adb 100644 --- a/src/contexts/MapDataContext.js +++ b/src/contexts/MapDataContext.js @@ -22,12 +22,8 @@ const cachedMapMax = 15; const defaultMapState = { tokens: {}, - // An index into the draw actions array to which only actions before the - // index will be performed (used in undo and redo) - mapDrawActionIndex: -1, - mapDrawActions: [], - fogDrawActionIndex: -1, - fogDrawActions: [], + drawShapes: {}, + fogShapes: {}, // Flags to determine what other people can edit editFlags: ["drawing", "tokens", "notes"], notes: {}, diff --git a/src/database.js b/src/database.js index 1c38512..9d04b82 100644 --- a/src/database.js +++ b/src/database.js @@ -2,6 +2,7 @@ import Dexie from "dexie"; import blobToBuffer from "./helpers/blobToBuffer"; import { getMapDefaultInset } from "./helpers/map"; +import { convertOldActionsToShapes } from "./actions"; function loadVersions(db) { // v1.2.0 @@ -305,7 +306,7 @@ function loadVersions(db) { }); }); - // 1.7.1 - Added note text only mode + // 1.8.0 - Added note text only mode, converted draw and fog representations db.version(18) .stores({}) .upgrade((tx) => { @@ -316,6 +317,20 @@ function loadVersions(db) { for (let id in state.notes) { state.notes[id].textOnly = false; } + + state.drawShapes = convertOldActionsToShapes( + state.mapDrawActions, + state.mapDrawActionIndex + ); + state.fogShapes = convertOldActionsToShapes( + state.fogDrawActions, + state.fogDrawActionIndex + ); + + delete state.mapDrawActions; + delete state.mapDrawActionIndex; + delete state.fogDrawActions; + delete state.fogDrawActionIndex; }); }); } diff --git a/src/helpers/diff.js b/src/helpers/diff.js index f2853fd..bb36900 100644 --- a/src/helpers/diff.js +++ b/src/helpers/diff.js @@ -1,4 +1,4 @@ -import { applyChange, diff as deepDiff } from "deep-diff"; +import { applyChange, revertChange, diff as deepDiff } from "deep-diff"; export function applyChanges(target, changes) { for (let change of changes) { @@ -6,4 +6,10 @@ export function applyChanges(target, changes) { } } +export function revertChanges(target, changes) { + for (let change of changes) { + revertChange(target, true, change); + } +} + export const diff = deepDiff; diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js index 397567d..7d1301f 100644 --- a/src/helpers/drawing.js +++ b/src/helpers/drawing.js @@ -2,7 +2,7 @@ import simplify from "simplify-js"; import polygonClipping from "polygon-clipping"; import * as Vector2 from "./vector2"; -import { toDegrees, omit } from "./shared"; +import { toDegrees } from "./shared"; import { getRelativePointerPositionNormalized } from "./konva"; import { logError } from "./logging"; @@ -219,80 +219,7 @@ export function simplifyPoints(points, gridSize, scale) { ); } -export function drawActionsToShapes(actions, actionIndex) { - let shapesById = {}; - for (let i = 0; i <= actionIndex; i++) { - const action = actions[i]; - if (!action) { - continue; - } - if (action.type === "add") { - for (let shape of action.shapes) { - shapesById[shape.id] = shape; - } - } - if (action.type === "edit") { - for (let edit of action.shapes) { - if (edit.id in shapesById) { - shapesById[edit.id] = { ...shapesById[edit.id], ...edit }; - } - } - } - if (action.type === "remove") { - shapesById = omit(shapesById, action.shapeIds); - } - if (action.type === "subtract") { - const actionGeom = action.shapes.map((actionShape) => [ - actionShape.data.points.map(({ x, y }) => [x, y]), - ]); - let subtractedShapes = {}; - for (let shape of Object.values(shapesById)) { - const shapePoints = shape.data.points.map(({ x, y }) => [x, y]); - const shapeHoles = shape.data.holes.map((hole) => - hole.map(({ x, y }) => [x, y]) - ); - let shapeGeom = [[shapePoints, ...shapeHoles]]; - const difference = polygonClipping.difference(shapeGeom, actionGeom); - addPolygonDifferenceToShapes(shape, difference, subtractedShapes); - } - shapesById = subtractedShapes; - } - if (action.type === "cut") { - const actionGeom = action.shapes.map((actionShape) => [ - actionShape.data.points.map(({ x, y }) => [x, y]), - ]); - let cutShapes = {}; - for (let shape of Object.values(shapesById)) { - const shapePoints = shape.data.points.map(({ x, y }) => [x, y]); - const shapeHoles = shape.data.holes.map((hole) => - hole.map(({ x, y }) => [x, y]) - ); - let shapeGeom = [[shapePoints, ...shapeHoles]]; - try { - const difference = polygonClipping.difference(shapeGeom, actionGeom); - const intersection = polygonClipping.intersection( - shapeGeom, - actionGeom - ); - addPolygonDifferenceToShapes(shape, difference, cutShapes); - addPolygonIntersectionToShapes(shape, intersection, cutShapes); - } catch { - logError( - new Error( - `Unable to find segment for shapes ${JSON.stringify( - shape - )} and ${JSON.stringify(action)}` - ) - ); - } - } - shapesById = cutShapes; - } - } - return Object.values(shapesById); -} - -function addPolygonDifferenceToShapes(shape, difference, shapes) { +export function addPolygonDifferenceToShapes(shape, difference, shapes) { for (let i = 0; i < difference.length; i++) { let newId = `${shape.id}-dif-${i}`; // Holes detected @@ -314,7 +241,7 @@ function addPolygonDifferenceToShapes(shape, difference, shapes) { } } -function addPolygonIntersectionToShapes(shape, intersection, shapes) { +export function addPolygonIntersectionToShapes(shape, intersection, shapes) { for (let i = 0; i < intersection.length; i++) { let newId = `${shape.id}-int-${i}`; shapes[newId] = { diff --git a/src/network/NetworkedMapAndTokens.js b/src/network/NetworkedMapAndTokens.js index 7a1675a..e26f059 100644 --- a/src/network/NetworkedMapAndTokens.js +++ b/src/network/NetworkedMapAndTokens.js @@ -17,6 +17,13 @@ import Session from "./Session"; import Map from "../components/map/Map"; import Tokens from "../components/token/Tokens"; +const defaultMapActions = { + mapDrawActions: [], + mapDrawActionIndex: -1, + fogDrawActions: [], + fogDrawActionIndex: -1, +}; + /** * @typedef {object} NetworkedMapProps * @property {Session} session @@ -225,58 +232,115 @@ function NetworkedMapAndTokens({ session }) { setCurrentMapState(newMapState, true, true); } - function addMapDrawActions(actions, indexKey, actionsKey) { - setCurrentMapState((prevMapState) => { + const [mapActions, setMapActions] = useState(defaultMapActions); + + function addMapActions(actions, indexKey, actionsKey, shapesKey) { + setMapActions((prevMapActions) => { const newActions = [ - ...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1), + ...prevMapActions[actionsKey].slice(0, prevMapActions[indexKey] + 1), ...actions, ]; const newIndex = newActions.length - 1; return { - ...prevMapState, + ...prevMapActions, [actionsKey]: newActions, [indexKey]: newIndex, }; }); + // Update map state by performing the actions on it + setCurrentMapState((prevMapState) => { + if (prevMapState) { + let shapes = prevMapState[shapesKey]; + for (let action of actions) { + shapes = action.execute(shapes); + } + return { + ...prevMapState, + [shapesKey]: shapes, + }; + } + }); } - function updateDrawActionIndex(change, indexKey, actionsKey) { + function updateActionIndex(change, indexKey, actionsKey, shapesKey) { + const prevIndex = mapActions[indexKey]; const newIndex = Math.min( - Math.max(currentMapState[indexKey] + change, -1), - currentMapState[actionsKey].length - 1 + Math.max(mapActions[indexKey] + change, -1), + mapActions[actionsKey].length - 1 ); - setCurrentMapState((prevMapState) => ({ - ...prevMapState, + setMapActions((prevMapActions) => ({ + ...prevMapActions, [indexKey]: newIndex, })); + + // Update map state by either performing the actions or undoing them + setCurrentMapState((prevMapState) => { + if (prevMapState) { + let shapes = prevMapState[shapesKey]; + if (prevIndex < newIndex) { + // Redo + for (let i = prevIndex + 1; i < newIndex + 1; i++) { + let action = mapActions[actionsKey][i]; + shapes = action.execute(shapes); + } + } else { + // Undo + for (let i = prevIndex; i > newIndex; i--) { + let action = mapActions[actionsKey][i]; + shapes = action.undo(shapes); + } + } + return { + ...prevMapState, + [shapesKey]: shapes, + }; + } + }); + return newIndex; } function handleMapDraw(action) { - addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions"); + addMapActions( + [action], + "mapDrawActionIndex", + "mapDrawActions", + "drawShapes" + ); } function handleMapDrawUndo() { - updateDrawActionIndex(-1, "mapDrawActionIndex", "mapDrawActions"); + updateActionIndex(-1, "mapDrawActionIndex", "mapDrawActions", "drawShapes"); } function handleMapDrawRedo() { - updateDrawActionIndex(1, "mapDrawActionIndex", "mapDrawActions"); + updateActionIndex(1, "mapDrawActionIndex", "mapDrawActions", "drawShapes"); } function handleFogDraw(action) { - addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions"); + addMapActions( + [action], + "fogDrawActionIndex", + "fogDrawActions", + "fogShapes" + ); } function handleFogDrawUndo() { - updateDrawActionIndex(-1, "fogDrawActionIndex", "fogDrawActions"); + updateActionIndex(-1, "fogDrawActionIndex", "fogDrawActions", "fogShapes"); } function handleFogDrawRedo() { - updateDrawActionIndex(1, "fogDrawActionIndex", "fogDrawActions"); + updateActionIndex(1, "fogDrawActionIndex", "fogDrawActions", "fogShapes"); } + useEffect(() => { + if (!currentMapState) { + setMapActions(defaultMapActions); + } + }, [currentMapState]); + function handleNoteChange(note) { setCurrentMapState((prevMapState) => ({ ...prevMapState, @@ -495,6 +559,7 @@ function NetworkedMapAndTokens({ session }) {