Converted draw actions storage to collapsed representation

Moved to command pattern for action application
This commit is contained in:
Mitchell McCaffrey 2021-02-04 09:11:27 +11:00
parent 2fc7f4f162
commit fa1f6fe18f
16 changed files with 366 additions and 137 deletions

View File

@ -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",

59
src/actions/Action.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

50
src/actions/index.js Normal file
View File

@ -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,
};

View File

@ -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 = (
<MapDrawing
map={map}
shapes={mapShapes}
shapes={drawShapes}
onShapeAdd={handleMapShapeAdd}
onShapesRemove={handleMapShapesRemove}
active={selectedToolId === "drawing"}

View File

@ -39,8 +39,8 @@ function MapTiles({
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 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;

View File

@ -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: {},

View File

@ -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;
});
});
}

View File

@ -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;

View File

@ -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] = {

View File

@ -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 }) {
<Map
map={currentMap}
mapState={currentMapState}
mapActions={mapActions}
onMapTokenStateChange={handleMapTokenStateChange}
onMapTokenStateRemove={handleMapTokenStateRemove}
onMapChange={handleMapChange}

View File

@ -7697,6 +7697,11 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"