diff --git a/app/frontend/src/common/annotation.ts b/app/frontend/src/common/annotation.ts index 8a8e27d..c3829c9 100644 --- a/app/frontend/src/common/annotation.ts +++ b/app/frontend/src/common/annotation.ts @@ -53,6 +53,20 @@ export function initialVertexBezier(vertexIndex: number, vertices: Vertex[]): No }; } +export function getEntityCategory( + e: Entity, + category: string, + sliceIndex: SliceIndex | undefined = undefined +): string[] { + if (sliceIndex !== undefined) { + const val = e.sliceCategories?.[sliceIndex]?.[category]; + return Object.keys(val ?? {}); + } + + const val = e.globalCategories?.[category]; + return Object.keys(val ?? {}); +} + export function setEntityCategory( e: Entity, category: string, diff --git a/app/frontend/src/component/panel/ActionBar.tsx b/app/frontend/src/component/panel/ActionBar.tsx index b963fbf..51eedc4 100644 --- a/app/frontend/src/component/panel/ActionBar.tsx +++ b/app/frontend/src/component/panel/ActionBar.tsx @@ -136,7 +136,7 @@ export const ActionBar: FC = ({...baseProps}) => { const focusAreas = useFocusAreas(canvasSize); // redo and undo - const {pastStates, futureStates, redo, undo} = useTemporalAnnoStore(); + const {undoCount, redoCount, redo, undo} = useTemporalAnnoStore(); return (
@@ -249,14 +249,14 @@ export const ActionBar: FC = ({...baseProps}) => { helpCode="action.undo" hotKey="⌘/⌃ + Z" icon={} - disabled={isDrawing || pastStates.length === 0} + disabled={isDrawing || undoCount === 0} onClick={() => undo()} /> } - disabled={isDrawing || futureStates.length === 0} + disabled={isDrawing || redoCount === 0} onClick={() => redo()} /> ()( - temporal( - subscribeWithSelector( - immer(set => ({ - annotation: emptyAnnotation, - setAnnotation: (annotation: Annotation | undefined) => { - set(s => { - s.annotation = annotation ?? emptyAnnotation; - }); - }, + subscribeWithSelector( + immer(set => ({ + annotation: emptyAnnotation, + setAnnotation: (annotation: Annotation | undefined) => { + set(s => { + s.annotation = annotation ?? emptyAnnotation; + }); + }, - setEntityCategory: (input: SetEntityCategoryInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, category, entries} = input; - const e = s.annotation.entities[entityId]; - setEntityCategory(e, category, entries, sliceIndex); + setEntityCategory: (input: SetEntityCategoryInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, category, entries} = input; + const e = s.annotation.entities[entityId]; + const oldEntries = getEntityCategory(e, category, sliceIndex); + setEntityCategory(e, category, entries, sliceIndex); + + if (opts.broadcast) { + const undo = () => { + useStore.getState().setEntityCategory( + { + sliceIndex, + entityId, + category, + entries: oldEntries, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().setEntityCategory(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - if (opts.broadcast) { - syncAnnotation.setEntityCategory(input); + syncAnnotation.setEntityCategory(input); + } + }); + }, + + clearEntityCategory: (input: ClearEntityCategoryInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, category} = input; + const e = s.annotation.entities[entityId]; + const oldEntries = getEntityCategory(e, category, sliceIndex); + + if (sliceIndex !== undefined) { + const cats = e?.sliceCategories; + const slice = cats?.[sliceIndex]; + delete slice?.[category]; + if (!!slice && Object.keys(slice).length === 0) { + delete cats?.[sliceIndex]; } - }); - }, - - clearEntityCategory: (input: ClearEntityCategoryInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, category} = input; - const e = s.annotation.entities[entityId]; - - if (sliceIndex !== undefined) { - const cats = e?.sliceCategories; - const slice = cats?.[sliceIndex]; - delete slice?.[category]; - if (!!slice && Object.keys(slice).length === 0) { - delete cats?.[sliceIndex]; - } - if (!!cats && Object.keys(cats).length === 0) { - delete e?.sliceCategories; - } - } else { - const cats = e?.globalCategories; - delete cats?.[category]; - if (!!cats && Object.keys(cats).length === 0) { - delete e?.globalCategories; - } + if (!!cats && Object.keys(cats).length === 0) { + delete e?.sliceCategories; } - - if (opts.broadcast) { - syncAnnotation.clearEntityCategory(input); + } else { + const cats = e?.globalCategories; + delete cats?.[category]; + if (!!cats && Object.keys(cats).length === 0) { + delete e?.globalCategories; } - }); - }, - - seperateComponent: (input: SeperateComponentInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, newEntityId, newComponentId} = input; - - const c = deleteComponent(s, sliceIndex, entityId, componentId); - if (!c) return; - - s.annotation.entities[newEntityId] = { - id: newEntityId, - geometry: { - slices: { - [sliceIndex]: { - [newComponentId]: deepClone({ - ...c, - id: newComponentId, - }), - }, + } + + if (opts.broadcast) { + const undo = () => { + useStore.getState().setEntityCategory( + { + sliceIndex, + entityId, + category, + entries: oldEntries, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().clearEntityCategory(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.clearEntityCategory(input); + } + }); + }, + + seperateComponent: (input: SeperateComponentInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, newEntityId, newComponentId} = input; + + const c = deleteComponent(s, sliceIndex, entityId, componentId); + if (!c) return; + + s.annotation.entities[newEntityId] = { + id: newEntityId, + geometry: { + slices: { + [sliceIndex]: { + [newComponentId]: deepClone({ + ...c, + id: newComponentId, + }), }, }, + }, + }; + + if (opts.broadcast) { + const undo = () => { + useStore.getState().transferComponent( + { + sliceIndex, + entityId: newEntityId, + componentId: newComponentId, + targetEntityId: entityId, + }, + {broadcast: true} + ); }; + const redo = () => { + // TODO(xu): check if this `newComponentId` indicates a bad design. + useStore.getState().seperateComponent({...input, componentId: newComponentId}, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - if (opts.broadcast) { - syncAnnotation.seperateComponent(input); - } - }); - }, + syncAnnotation.seperateComponent(input); + } + }); + }, - addComponent: (input: AddComponentInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, component} = input; - addComponent(s, sliceIndex, entityId, component); + addComponent: (input: AddComponentInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, component} = input; + addComponent(s, sliceIndex, entityId, component); + + if (opts.broadcast) { + const undo = () => { + useStore.getState().deleteComponents( + { + sliceIndex, + components: [[entityId, component.id]], + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().addComponent(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - if (opts.broadcast) { - syncAnnotation.addComponent(input); - } - }); - }, + syncAnnotation.addComponent(input); + } + }); + }, - addComponents: (input: AddComponentsInput, opts = {}) => { - set(s => { - const {entityId, components} = input; - components.forEach(({sliceIndex, component}) => addComponent(s, sliceIndex, entityId, component)); + addComponents: (input: AddComponentsInput, opts = {}) => { + set(s => { + const {entityId, components} = input; + components.forEach(({sliceIndex, component}) => addComponent(s, sliceIndex, entityId, component)); + + if (opts.broadcast) { + const undo = () => { + components.forEach(({sliceIndex, component}) => { + useStore.getState().deleteComponents( + { + sliceIndex, + components: [[entityId, component.id]], + }, + {broadcast: true} + ); + }); + }; + const redo = () => { + useStore.getState().addComponents(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - if (opts.broadcast) { - syncAnnotation.addComponents(input); - } - }); - }, + syncAnnotation.addComponents(input); + } + }); + }, - updatePolychainVertices: (input: UpdatePolychainVerticesInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, vertices} = input; + updatePolychainVertices: (input: UpdatePolychainVerticesInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, vertices} = input; - const slice = getSlice(s, sliceIndex, entityId); - if (!slice) return; + const slice = getSlice(s, sliceIndex, entityId); + if (!slice) return; - const old = slice[componentId]; - if (old?.type !== 'polychain') return; + const old = slice[componentId]; + if (old?.type !== 'polychain') return; - slice[componentId] = {...old, vertices}; + slice[componentId] = {...old, vertices}; - if (opts.broadcast) { - syncAnnotation.updatePolychainVertices(input); - } - }); - }, + if (opts.broadcast) { + const oldVertices = deepClone(old.vertices); + const undo = () => { + useStore.getState().updatePolychainVertices( + { + sliceIndex, + entityId, + componentId, + vertices: oldVertices, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().updatePolychainVertices(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - transferComponent: (input: TransferComponentInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, targetEntityId} = input; + syncAnnotation.updatePolychainVertices(input); + } + }); + }, - const c = deleteComponent(s, sliceIndex, entityId, componentId); - if (!c) return; + transferComponent: (input: TransferComponentInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, targetEntityId} = input; - addComponent(s, sliceIndex, targetEntityId, c); + const c = deleteComponent(s, sliceIndex, entityId, componentId); + if (!c) return; - if (opts.broadcast) { - syncAnnotation.transferComponent(input); - } - }); - }, + addComponent(s, sliceIndex, targetEntityId, c); - deleteEntities: (input: DeleteEntitiesInput, opts = {}) => { - set(s => { - const {entityIds} = input; + if (opts.broadcast) { + const component = deepClone(c); - entityIds.forEach(eid => { - delete s.annotation.entities[eid]; - }); + const undo = () => { + useStore.getState().deleteComponents( + { + sliceIndex, + components: [[targetEntityId, component.id]], + }, + {broadcast: true} + ); + useStore.getState().addComponent( + { + sliceIndex, + entityId, + component, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().transferComponent(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - if (opts.broadcast) { - syncAnnotation.deleteEntities(input); - } - }); - }, - - truncateEntities: (input: TruncateEntitiesInput, opts = {}) => { - set(s => { - const {entityIds, sinceSliceIndex} = input; - - entityIds.forEach(eid => { - const slices = s.annotation.entities[eid].geometry.slices; - const sidxs = Object.keys(slices).map(s => parseInt(s)); - for (const sidx of sidxs) { - if (sidx >= sinceSliceIndex) { - delete slices[sidx]; - } - } - }); + syncAnnotation.transferComponent(input); + } + }); + }, - if (opts.broadcast) { - syncAnnotation.truncateEntities(input); - } + deleteEntities: (input: DeleteEntitiesInput, opts = {}) => { + set(s => { + const {entityIds} = input; + + const deleted = entityIds.map(eid => s.annotation.entities[eid]); + entityIds.forEach(eid => { + delete s.annotation.entities[eid]; }); - }, - deleteComponents: (input: DeleteComponentsInput, opts = {}) => { - set(s => { - const {sliceIndex, components} = input; - components.forEach(([eid, cid]) => deleteComponent(s, sliceIndex, eid, cid)); + if (opts.broadcast) { + const entities = deepClone(deleted); + const undo = () => { + const annoState = useStore.getState(); + + // add back components + const inputs: AddComponentsInput[] = []; + entities.forEach(e => { + Object.entries(e.geometry.slices).map(([sidx, sliceComponents]) => { + const components = Object.values(sliceComponents).map(component => ({ + sliceIndex: parseInt(sidx), + component, + })); + inputs.push({entityId: e.id, components}); + }); + }); + inputs.forEach(input => { + annoState.addComponents(input, {broadcast: true}); + }); - if (opts.broadcast) { - syncAnnotation.deleteComponents(input); - } - }); - }, - - commitDraftComponents: (opts = {}) => { - set(s => { - Object.values(s.annotation.entities).forEach(entity => { - Object.entries(entity.geometry.slices).forEach(([sidx, sliceComponents]) => { - Object.values(sliceComponents).forEach(component => { - delete component.draft; - - if (opts.broadcast) { - syncAnnotation.addComponent({ - sliceIndex: parseInt(sidx), - entityId: entity.id, - component, - }); - } + // add back categories + entities.forEach(e => { + Object.entries(e.globalCategories ?? {}).forEach(([category, es]) => { + annoState.setEntityCategory( + { + entityId: e.id, + category, + entries: Object.keys(es), + }, + {broadcast: true} + ); + }); + Object.entries(e.sliceCategories ?? {}).forEach(([sidx, sliceCategories]) => { + Object.entries(sliceCategories).forEach(([category, es]) => { + annoState.setEntityCategory( + { + entityId: e.id, + sliceIndex: parseInt(sidx), + category, + entries: Object.keys(es), + }, + {broadcast: true} + ); + }); }); }); - }); - }); - }, + }; + const redo = () => { + useStore.getState().deleteEntities(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - deletePolychainVertex: (input: DeletePolychainVertexInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, vertexIndex} = input; + syncAnnotation.deleteEntities(input); + } + }); + }, - const c = getComponent(s, sliceIndex, entityId, componentId); - if (c?.type !== 'polychain') return; + truncateEntities: (input: TruncateEntitiesInput, opts = {}) => { + set(s => { + const {entityIds, sinceSliceIndex} = input; - const n = c.vertices.length; - if ((c.closed && n <= 3) || (!c.closed && n <= 2)) { - deleteComponent(s, sliceIndex, entityId, componentId); - } else { - c.vertices.splice(vertexIndex, 1); + const entities = deepClone(entityIds.map(eid => s.annotation.entities[eid])); + entityIds.forEach(eid => { + const e = s.annotation.entities[eid]; - // The first vertex of a polyline can NOT be bezier. - if (!c.closed && c.vertices[0].bezier) { - c.vertices[0].bezier = undefined; + const slices = e.geometry.slices; + const sidxs = Object.keys(slices).map(s => parseInt(s)); + for (const sidx of sidxs) { + if (sidx >= sinceSliceIndex) { + delete slices[sidx]; } } - if (opts.broadcast) { - syncAnnotation.deletePolychainVertex(input); + const sidxs2 = Object.keys(e.sliceCategories ?? {}).map(s => parseInt(s)); + for (const sidx of sidxs2) { + if (sidx >= sinceSliceIndex) { + delete e.sliceCategories?.[sidx]; + } } }); - }, - setPolychainVertexBezier: (input: SetPolychainVertexBezierInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, vertexIndex, isBezier} = input; + if (opts.broadcast) { + const undo = () => { + const annoState = useStore.getState(); + + // add back components + const inputs: AddComponentsInput[] = []; + entities.forEach(e => { + Object.entries(e.geometry.slices).map(([sidxStr, sliceComponents]) => { + const sidx = parseInt(sidxStr); + if (sidx < sinceSliceIndex) { + return; + } + const components = Object.values(sliceComponents).map(component => ({ + sliceIndex: sidx, + component, + })); + inputs.push({entityId: e.id, components}); + }); + }); + inputs.forEach(input => { + annoState.addComponents(input, {broadcast: true}); + }); - const c = getComponent(s, sliceIndex, entityId, componentId); - if (c?.type !== 'polychain') return; + // add back categories + entities.forEach(e => { + Object.entries(e.sliceCategories ?? {}).forEach(([sidxStr, sliceCategories]) => { + const sidx = parseInt(sidxStr); + if (sidx < sinceSliceIndex) { + return; + } + Object.entries(sliceCategories).forEach(([category, es]) => { + annoState.setEntityCategory( + { + entityId: e.id, + sliceIndex: sidx, + category, + entries: Object.keys(es), + }, + {broadcast: true} + ); + }); + }); + }); + }; + const redo = () => { + useStore.getState().truncateEntities(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); - const curr = c.vertices[vertexIndex]; - if (isBezier) { - curr.bezier = initialVertexBezier(vertexIndex, c.vertices); - } else { - delete curr.bezier; - } + syncAnnotation.truncateEntities(input); + } + }); + }, - if (opts.broadcast) { - syncAnnotation.setPolychainVertexBezier(input); - } + deleteComponents: (input: DeleteComponentsInput, opts = {}) => { + set(s => { + const {sliceIndex, components} = input; + const deleted: [EntityId, Component | undefined][] = components.map(([eid, cid]) => [ + eid, + deleteComponent(s, sliceIndex, eid, cid), + ]); + + if (opts.broadcast) { + const cloned = deepClone(deleted); + const undo = () => { + cloned.forEach(([entityId, component]) => { + if (!component) { + return; + } + useStore.getState().addComponent({sliceIndex, entityId, component}, {broadcast: true}); + }); + }; + const redo = () => { + useStore.getState().deleteComponents(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.deleteComponents(input); + } + }); + }, + + commitDraftComponents: (opts = {}) => { + set(s => { + const addComponentInputs: AddComponentInput[] = []; + + Object.values(s.annotation.entities).forEach(entity => { + Object.entries(entity.geometry.slices).forEach(([sidx, sliceComponents]) => { + Object.values(sliceComponents).forEach(component => { + delete component.draft; + + addComponentInputs.push({ + sliceIndex: parseInt(sidx), + entityId: entity.id, + component, + }); + }); + }); }); - }, - updateRectangleAnchors: (input: UpdateRectangleAnchorsInput, opts = {}) => { - set(s => { - const {sliceIndex, entityId, componentId, topLeft, bottomRight} = input; + if (opts.broadcast) { + // TODO(xu): not tested. + const cloned = deepClone(addComponentInputs); + const undo = () => { + cloned.forEach(({sliceIndex, entityId, component}) => { + useStore.getState().deleteComponents( + { + sliceIndex, + components: [[entityId, component.id]], + }, + {broadcast: true} + ); + }); + }; + const redo = () => { + addComponentInputs.forEach(input => useStore.getState().addComponent(input, {broadcast: true})); + }; + useHistoryStore.getState().pushAction({undo, redo}); - const slice = getSlice(s, sliceIndex, entityId); - if (!slice) return; + addComponentInputs.forEach(input => syncAnnotation.addComponent(input)); + } + }); + }, - const old = slice[componentId]; - if (old?.type !== 'rectangle') return; + deletePolychainVertex: (input: DeletePolychainVertexInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, vertexIndex} = input; - slice[componentId] = {...old, topLeft, bottomRight}; + const c = getComponent(s, sliceIndex, entityId, componentId); + if (c?.type !== 'polychain') return; - if (opts.broadcast) { - syncAnnotation.updateRectangleAnchors(input); - } - }); - }, + const old = deepClone(c); - updateSliceMasks: (input: UpdateSliceMasksInput, opts = {}) => { - set(s => { - const {sliceIndex, removes, adds} = input; - removes.forEach(({entityId: eid, componentId: cid}) => deleteComponent(s, sliceIndex, eid, cid)); - adds.forEach(({entityId: eid, component: c}) => addComponent(s, sliceIndex, eid, c)); + const n = c.vertices.length; + const shouldDelete = (c.closed && n <= 3) || (!c.closed && n <= 2); + if (shouldDelete) { + deleteComponent(s, sliceIndex, entityId, componentId); + } else { + c.vertices.splice(vertexIndex, 1); - if (opts.broadcast) { - syncAnnotation.updateSliceMasks(input); + // The first vertex of a polyline can NOT be bezier. + if (!c.closed && c.vertices[0].bezier) { + c.vertices[0].bezier = undefined; } - }); - }, - - paste: (input: PasteInput, opts = {}) => { - const {entityComponents: ecs, sourceSliceIndex: sidx0, targetSliceIndex: sidx1} = input; - set(s => { - ecs.forEach(({entityId: eid, componentId: cid0, newComponentId: cid1}) => { - const c0 = getComponent(s, sidx0, eid, cid0); - if (c0) { - const c1 = deepClone({...c0, id: cid1}); - addComponent(s, sidx1, eid, c1); + } + + if (opts.broadcast) { + const undo = () => { + const annoStore = useStore.getState(); + + if (shouldDelete) { + annoStore.addComponent({sliceIndex, entityId, component: old}, {broadcast: true}); + } else { + annoStore.updatePolychainVertices( + {sliceIndex, entityId, componentId, vertices: old.vertices}, + {broadcast: true} + ); } - }); + }; + const redo = () => { + useStore.getState().deletePolychainVertex(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.deletePolychainVertex(input); + } + }); + }, + + setPolychainVertexBezier: (input: SetPolychainVertexBezierInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, vertexIndex, isBezier} = input; + + const c = getComponent(s, sliceIndex, entityId, componentId); + if (c?.type !== 'polychain') return; + + const curr = c.vertices[vertexIndex]; + if (isBezier) { + curr.bezier = initialVertexBezier(vertexIndex, c.vertices); + } else { + delete curr.bezier; + } + + if (opts.broadcast) { + const undo = () => { + useStore.getState().setPolychainVertexBezier({...input, isBezier: !input.isBezier}, {broadcast: true}); + }; + const redo = () => { + useStore.getState().setPolychainVertexBezier(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.setPolychainVertexBezier(input); + } + }); + }, + + updateRectangleAnchors: (input: UpdateRectangleAnchorsInput, opts = {}) => { + set(s => { + const {sliceIndex, entityId, componentId, topLeft, bottomRight} = input; - if (opts.broadcast) { - syncAnnotation.paste(input); + const slice = getSlice(s, sliceIndex, entityId); + if (!slice) return; + + const old = slice[componentId]; + if (old?.type !== 'rectangle') return; + + slice[componentId] = {...old, topLeft, bottomRight}; + + if (opts.broadcast) { + const {topLeft: topLeftOld, bottomRight: bottomRightOld} = deepClone(old); + const undo = () => { + useStore.getState().updateRectangleAnchors( + { + sliceIndex, + entityId, + componentId, + topLeft: topLeftOld, + bottomRight: bottomRightOld, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().updateRectangleAnchors(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.updateRectangleAnchors(input); + } + }); + }, + + updateSliceMasks: (input: UpdateSliceMasksInput, opts = {}) => { + set(s => { + const {sliceIndex, removes, adds} = input; + const removed: [EntityId, Component | undefined][] = removes.map(({entityId: eid, componentId: cid}) => [ + eid, + deleteComponent(s, sliceIndex, eid, cid), + ]); + adds.forEach(({entityId: eid, component: c}) => addComponent(s, sliceIndex, eid, c)); + + if (opts.broadcast) { + const removedCloned = deepClone(removed); + const undo = () => { + const undoRemoves = adds.map(({entityId, component}) => ({entityId, componentId: component.id})); + const undoAdds: UpdateSliceMasksInput['adds'] = []; + removedCloned.forEach(([entityId, component]) => { + if (component?.type === 'mask') { + undoAdds.push({entityId, component}); + } + }); + useStore.getState().updateSliceMasks( + { + sliceIndex, + adds: undoAdds, + removes: undoRemoves, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().updateSliceMasks(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.updateSliceMasks(input); + } + }); + }, + + paste: (input: PasteInput, opts = {}) => { + const {entityComponents: ecs, sourceSliceIndex: sidx0, targetSliceIndex: sidx1} = input; + set(s => { + const pasted: [EntityId, ComponentId][] = []; + ecs.forEach(({entityId: eid, componentId: cid0, newComponentId: cid1}) => { + const c0 = getComponent(s, sidx0, eid, cid0); + if (c0) { + const c1 = deepClone({...c0, id: cid1}); + addComponent(s, sidx1, eid, c1); + pasted.push([eid, c1.id]); } }); - }, - - translate: (input: TranslateInput, opts = {}) => { - const {entityComponents: ecs, offsetX: dx, offsetY: dy, sliceIndex: sidx} = input; - set(s => { - ecs.forEach(({entityId: eid, componentId: cid}) => { - const c = getComponent(s, sidx, eid, cid); - const slice = getSlice(s, sidx, eid); - if (c && slice) { - const adapter = newComponentAdapter(c); - const translated = adapter.translate({x: dx, y: dy}); - slice[cid] = {id: cid, ...translated}; - } - }); - if (opts.broadcast) { - syncAnnotation.translate(input); + if (opts.broadcast) { + const undo = () => { + useStore.getState().deleteComponents( + { + sliceIndex: sidx1, + components: pasted, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().paste(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.paste(input); + } + }); + }, + + translate: (input: TranslateInput, opts = {}) => { + const {entityComponents: ecs, offsetX: dx, offsetY: dy, sliceIndex: sidx} = input; + set(s => { + ecs.forEach(({entityId: eid, componentId: cid}) => { + const c = getComponent(s, sidx, eid, cid); + const slice = getSlice(s, sidx, eid); + if (c && slice) { + const adapter = newComponentAdapter(c); + const translated = adapter.translate({x: dx, y: dy}); + slice[cid] = {id: cid, ...translated}; } }); - }, - })) - ), - { - partialize: state => { - const {annotation} = state; - return {annotation}; + + if (opts.broadcast) { + const undo = () => { + useStore.getState().translate( + { + ...input, + offsetX: -input.offsetX, + offsetY: -input.offsetY, + }, + {broadcast: true} + ); + }; + const redo = () => { + useStore.getState().translate(input, {broadcast: true}); + }; + useHistoryStore.getState().pushAction({undo, redo}); + + syncAnnotation.translate(input); + } + }); }, - equality: (past, curr) => deepEqual(past, curr), - } + })) ) ); -export const useTemporalStore = create(useStore.temporal); - function addComponent(s: State, sliceIndex: SliceIndex, entityId: EntityId, component: Component) { addAnnotationComponent(s.annotation, sliceIndex, entityId, component); } @@ -545,3 +907,97 @@ export function deleteAnnotationComponent( return component; } + +type VoidAction = () => void; + +interface ActionConjugation { + undo: VoidAction; + redo: VoidAction; +} + +const noop = () => {}; +const initialAction: ActionConjugation = { + undo: noop, + redo: noop, +}; + +type HistoryState = { + actions: ActionConjugation[]; + index: number; + + pushAction: (action: ActionConjugation) => void; + undo: () => void; + redo: () => void; + reset: () => void; +}; + +const useHistoryStore = create()( + immer(set => ({ + actions: [initialAction], + index: 0, + + pushAction: (action: ActionConjugation) => { + set(s => { + if (s.index === s.actions.length - 1) { + s.actions.push(action); + } else { + s.actions = [initialAction, action]; + } + s.index = s.actions.length - 1; + }); + }, + + undo: () => { + set(s => { + if (s.index === 0) { + return; + } + const {undo: fn} = s.actions[s.index]; + s.index--; + fn(); + }); + }, + + redo: () => { + set(s => { + if (s.index === s.actions.length - 1) { + return; + } + const {redo: fn} = s.actions[s.index + 1]; + s.index++; + fn(); + }); + }, + + reset: () => { + set(s => { + s.actions = [initialAction]; + s.index = 0; + }); + }, + })) +); + +export interface TemporalStore { + undo: () => void; + redo: () => void; + clear: () => void; + redoCount: number; + undoCount: number; +} + +export function useTemporalStore(): TemporalStore { + const undo = useHistoryStore(s => s.undo); + const redo = useHistoryStore(s => s.redo); + const undoCount = useHistoryStore(s => s.index); + const redoCount = useHistoryStore(s => s.actions.length - s.index - 1); + const clear = useHistoryStore(s => s.reset); + + return { + undo, + redo, + clear, + redoCount, + undoCount, + }; +}