diff --git a/app/frontend/src/component/panel/layer/Hover.tsx b/app/frontend/src/component/panel/layer/Hover.tsx index 145a9cc..5ec082d 100644 --- a/app/frontend/src/component/panel/layer/Hover.tsx +++ b/app/frontend/src/component/panel/layer/Hover.tsx @@ -28,7 +28,7 @@ import {ComponentProximity} from 'state/annotate/render/mouse'; import {ColorPalette} from '../entity/display'; import {editStyle} from 'common/constant'; import {getComponent} from 'state/annotate/annotation'; -import {useAddComponents, useTransferComponent} from 'state/annotate/annotation-broadcast'; +import {useAddDeleteComponents, useTransferComponent} from 'state/annotate/annotation-broadcast'; const FullSize: CSSProperties = {position: 'absolute', left: 0, top: 0, width: '100%', height: '100%'}; @@ -174,7 +174,7 @@ const TopLevelHover: FC> = ({...divProps}) => { [manipulation] ) ); - const {addComponents} = useAddComponents(); + const {addComponents} = useAddDeleteComponents(); const {transferComponent} = useTransferComponent(); const {isControlOrMetaPressed, isShiftPressed} = useControlMetaShiftPressed(); diff --git a/app/frontend/src/component/panel/layer/polychain/Draw.tsx b/app/frontend/src/component/panel/layer/polychain/Draw.tsx index 8e724eb..8802df1 100644 --- a/app/frontend/src/component/panel/layer/polychain/Draw.tsx +++ b/app/frontend/src/component/panel/layer/polychain/Draw.tsx @@ -23,7 +23,7 @@ import {ColorPalette} from 'component/panel/entity/display'; import {useHotkeys} from 'react-hotkeys-hook'; import {useKeyPressed} from 'common/keyboard'; import {getComponent} from 'state/annotate/annotation'; -import {useAddComponent} from 'state/annotate/annotation-broadcast'; +import {useAddDeleteComponents} from 'state/annotate/annotation-broadcast'; type Props = HTMLAttributes & { width: number; @@ -89,7 +89,7 @@ const LayerWithEntityId: FC = ({entityId, width, h ) ); - const {addComponent} = useAddComponent(); + const {addComponent} = useAddDeleteComponents(); const drawPolychain = useDrawPolychain(transform); const drawAnnoVertex = useDrawVertex(transform); diff --git a/app/frontend/src/component/panel/layer/rectangle/Draw.tsx b/app/frontend/src/component/panel/layer/rectangle/Draw.tsx index a19643b..907e196 100644 --- a/app/frontend/src/component/panel/layer/rectangle/Draw.tsx +++ b/app/frontend/src/component/panel/layer/rectangle/Draw.tsx @@ -14,7 +14,7 @@ import {EntityId} from 'type/annotation'; import {useHotkeys} from 'react-hotkeys-hook'; import {Button, Space, Tag, Tooltip} from 'antd'; import {ClearOutlined} from '@ant-design/icons'; -import {useAddComponent} from 'state/annotate/annotation-broadcast'; +import {useAddDeleteComponents} from 'state/annotate/annotation-broadcast'; type Props = HTMLAttributes & { width: number; @@ -50,7 +50,7 @@ const LayerWithEntityId: FC = ({entityId, width, h ) ); - const {addComponent} = useAddComponent(); + const {addComponent} = useAddDeleteComponents(); useHotkeys( 'esc', useCallback(() => finish(), [finish]) diff --git a/app/frontend/src/component/panel/menu/common.ts b/app/frontend/src/component/panel/menu/common.ts index b92c8ee..1f29c67 100644 --- a/app/frontend/src/component/panel/menu/common.ts +++ b/app/frontend/src/component/panel/menu/common.ts @@ -9,7 +9,7 @@ import {useStore as useRenderStore} from 'state/annotate/render'; import type {EntityId, ComponentId} from 'type/annotation'; import {getSlice} from 'state/annotate/annotation'; import { - useDeleteComponents, + useAddDeleteComponents, useDeleteEntities, usePaste, useSeparateComponent, @@ -31,7 +31,7 @@ export function useComponentActions(entityId: EntityId, componentId: ComponentId const sliceIndex = useRenderStore(s => s.sliceIndex); const {separateComponent} = useSeparateComponent(); - const {deleteComponents} = useDeleteComponents(); + const {deleteComponents} = useAddDeleteComponents(); const startManipulation = useRenderStore(s => s.manipulate.start); const nc = useAnnoStore( @@ -80,7 +80,7 @@ export function useEntityActions(): Action[] { const sliceIndex = useRenderStore(s => s.sliceIndex); const {deleteEntities} = useDeleteEntities(); - const {deleteComponents} = useDeleteComponents(); + const {deleteComponents} = useAddDeleteComponents(); const {truncateEntities} = useTruncateEntities(); // copy diff --git a/app/frontend/src/component/panel/menu/mask.ts b/app/frontend/src/component/panel/menu/mask.ts index f0ddf2f..f3fb91d 100644 --- a/app/frontend/src/component/panel/menu/mask.ts +++ b/app/frontend/src/component/panel/menu/mask.ts @@ -9,7 +9,7 @@ import {ConfigContext} from 'common/context'; import {expand, rleCountsFromStringCOCO, rleCountsToStringCOCO, shrink} from 'common/algorithm/rle'; import {Mask, TrackReq} from 'openapi/nutsh'; import {correctSliceUrl} from 'common/route'; -import {useAddComponents} from 'state/annotate/annotation-broadcast'; +import {useAddDeleteComponents} from 'state/annotate/annotation-broadcast'; export function useActions(mask: MaskComponent, eid: EntityId): Action[] { const config = useContext(ConfigContext); @@ -22,7 +22,7 @@ export function useActions(mask: MaskComponent, eid: EntityId): Action[] { const currentSliceIndex = useRenderStore(s => s.sliceIndex); const currentSliceUrl = useRenderStore(s => correctSliceUrl(s.sliceUrls[s.sliceIndex])); const subsequentSliceUrls = useRenderStore(s => s.sliceUrls.slice(s.sliceIndex + 1).map(correctSliceUrl)); - const {addComponents} = useAddComponents(); + const {addComponents} = useAddDeleteComponents(); const track = useCallback( (mask: MaskComponent) => { diff --git a/app/frontend/src/state/annotate/annotation-broadcast.ts b/app/frontend/src/state/annotate/annotation-broadcast.ts index 3fa7d9c..2d0950f 100644 --- a/app/frontend/src/state/annotate/annotation-broadcast.ts +++ b/app/frontend/src/state/annotate/annotation-broadcast.ts @@ -7,6 +7,8 @@ import type { RectangleComponent, Entity, SliceIndex, + EntityId, + Component, } from 'type/annotation'; import {newComponentAdapter} from 'common/adapter'; import {initialVertexBezier} from 'common/annotation'; @@ -198,11 +200,48 @@ export function useSeparateComponent(): Pick { +export function useAddDeleteComponents(): Pick< + StateManipulation, + 'addComponent' | 'addComponents' | 'deleteComponents' +> { const {doc} = useYjsContext(); + const comps = yjsComponentMap(doc); + const annoStore = useAnnoStoreRaw(); const pushAction = useAnnoHistoryStore(s => s.pushAction); - const {deleteComponents} = useDeleteComponents(); + + const deleteComponents = (input: DeleteComponentsInput) => { + const {components, sliceIndex} = input; + const deletedComponents: [EntityId, Component | undefined][] = components.map(([eid, cid]) => [ + eid, + deepClone(getComponent(annoStore.getState(), sliceIndex, eid, cid)), + ]); + + doc.transact(() => { + components.forEach(([, cid]) => { + const comp = comps.get(cid); + if (!comp) { + console.warn(`component ${cid} not found`); + return; + } + deleteComponentFromDoc(doc, cid, comp.type); + }); + }); + + const undo = () => { + deletedComponents.forEach(([entityId, component]) => { + if (!component) { + return; + } + addComponent({sliceIndex, entityId, component}); + }); + }; + const redo = () => { + deleteComponents(input); + }; + pushAction({undo, redo}); + }; + const addComponent = (input: AddComponentInput) => { const {sliceIndex, entityId, component} = input; addComponentToDoc(doc, input); @@ -219,14 +258,6 @@ export function useAddComponent(): Pick { pushAction({undo, redo}); }; - return {addComponent}; -} - -export function useAddComponents(): Pick { - const {doc} = useYjsContext(); - - const pushAction = useAnnoHistoryStore(s => s.pushAction); - const {deleteComponents} = useDeleteComponents(); const addComponents = (input: AddComponentsInput) => { const {entityId, components} = input; doc.transact(() => { @@ -247,7 +278,7 @@ export function useAddComponents(): Pick { pushAction({undo, redo}); }; - return {addComponents}; + return {addComponent, addComponents, deleteComponents}; } export function useUpdatePolychainVertices(): Pick { @@ -289,8 +320,7 @@ export function useTransferComponent(): Pick s.pushAction); - const {addComponent} = useAddComponent(); - const {deleteComponents} = useDeleteComponents(); + const {addComponent, deleteComponents} = useAddDeleteComponents(); const transferComponent = (input: TransferComponentInput) => { const {sliceIndex: sidx, entityId: eid, componentId: cid, targetEntityId} = input; @@ -338,7 +368,7 @@ export function useDeleteEntities(): Pick { const comps = yjsComponentMap(doc); const annoStore = useAnnoStoreRaw(); - const {addComponents} = useAddComponents(); + const {addComponents} = useAddDeleteComponents(); const {setEntityCategory} = useSetEntityCategory(); const pushAction = useAnnoHistoryStore(s => s.pushAction); const deleteEntities = (input: DeleteEntitiesInput) => { @@ -411,7 +441,7 @@ export function useTruncateEntities(): Pick s.pushAction); const truncateEntities = (input: TruncateEntitiesInput) => { @@ -492,59 +522,59 @@ export function useTruncateEntities(): Pick { - const {doc} = useYjsContext(); - const comps = yjsComponentMap(doc); - - const deleteComponents = (input: DeleteComponentsInput) => { - const {components} = input; - doc.transact(() => { - components.forEach(([, cid]) => { - const comp = comps.get(cid); - if (!comp) { - console.warn(`component ${cid} not found`); - return; - } - deleteComponentFromDoc(doc, cid, comp.type); - }); - }); - }; - - return {deleteComponents}; -} - export function useDeletePolychainVertex(): Pick { const {doc} = useYjsContext(); const comps = yjsComponentMap(doc); const verts = yjsPolychainVerticesMap(doc); + const annoStore = useAnnoStoreRaw(); + const {addComponent} = useAddDeleteComponents(); + const {updatePolychainVertices} = useUpdatePolychainVertices(); + const pushAction = useAnnoHistoryStore(s => s.pushAction); const deletePolychainVertex = (input: DeletePolychainVertexInput) => { - const {componentId: cid, vertexIndex} = input; + const {componentId: cid, vertexIndex, sliceIndex, entityId} = input; + const old = deepClone(getComponent(annoStore.getState(), sliceIndex, entityId, cid)); + if (!old || old.type !== 'polychain') { + return; + } const comp = comps.get(cid); const vs = verts.get(cid); if (!vs || comp?.type !== 'polychain') { return; } + const n = vs.length; - if ((comp.closed && n <= 3) || (!comp.closed && n <= 2)) { + const deleteComponent = (comp.closed && n <= 3) || (!comp.closed && n <= 2); + if (deleteComponent) { deleteComponentFromDoc(doc, cid, comp.type); - return; + } else { + doc.transact(() => { + if (vertexIndex > 0 || comp.closed) { + vs.delete(vertexIndex, 1); + } else { + // The first vertex of a polyline can NOT be bezier. + if (vs.get(1).bezier) { + const v = vs.get(1); + vs.delete(1, 1); + vs.insert(1, [{...v, bezier: undefined}]); + } + vs.delete(0, 1); + } + }); } - doc.transact(() => { - if (vertexIndex > 0 || comp.closed) { - vs.delete(vertexIndex, 1); + const undo = () => { + if (deleteComponent) { + addComponent({sliceIndex, entityId, component: old}); } else { - // The first vertex of a polyline can NOT be bezier. - if (vs.get(1).bezier) { - const v = vs.get(1); - vs.delete(1, 1); - vs.insert(1, [{...v, bezier: undefined}]); - } - vs.delete(0, 1); + updatePolychainVertices({sliceIndex, entityId, componentId: cid, vertices: old.vertices}); } - }); + }; + const redo = () => { + deletePolychainVertex(input); + }; + pushAction({undo, redo}); }; return {deletePolychainVertex}; @@ -554,6 +584,7 @@ export function useSetPolychainVertexBezier(): Pick s.pushAction); const setPolychainVertexBezier = (input: SetPolychainVertexBezierInput) => { const {componentId: cid, vertexIndex, isBezier} = input; const vs = verts.get(cid); @@ -575,6 +606,14 @@ export function useSetPolychainVertexBezier(): Pick { + setPolychainVertexBezier({...input, isBezier: !input.isBezier}); + }; + const redo = () => { + setPolychainVertexBezier(input); + }; + pushAction({undo, redo}); }; return {setPolychainVertexBezier}; @@ -584,9 +623,31 @@ export function useUpdateRectangleAnchors(): Pick s.pushAction); const updateRectangleAnchors = (input: UpdateRectangleAnchorsInput) => { - const {componentId, topLeft, bottomRight} = input; + const {componentId, topLeft, bottomRight, sliceIndex, entityId} = input; + const old = deepClone(getComponent(annoStore.getState(), sliceIndex, entityId, componentId)); + if (old?.type !== 'rectangle') { + return; + } + anchors.set(componentId, {topLeft, bottomRight}); + + const {topLeft: topLeftOld, bottomRight: bottomRightOld} = deepClone(old); + const undo = () => { + updateRectangleAnchors({ + sliceIndex, + entityId, + componentId, + topLeft: topLeftOld, + bottomRight: bottomRightOld, + }); + }; + const redo = () => { + updateRectangleAnchors(input); + }; + pushAction({undo, redo}); }; return {updateRectangleAnchors}; @@ -597,8 +658,16 @@ export function useUpdateSliceMasks(): Pick s.pushAction); const updateSliceMasks = (input: UpdateSliceMasksInput) => { const {sliceIndex: sidx, removes, adds} = input; + + const removedMasks: [EntityId, Component | undefined][] = removes.map(({entityId: eid, componentId: cid}) => [ + eid, + deepClone(getComponent(annoStore.getState(), sidx, eid, cid)), + ]); + doc.transact(() => { adds.forEach(({entityId: eid, component: c}) => { masks.set(c.id, c); @@ -608,6 +677,26 @@ export function useUpdateSliceMasks(): Pick { + const undoRemoves = adds.map(({entityId, component}) => ({entityId, componentId: component.id})); + const undoAdds: UpdateSliceMasksInput['adds'] = []; + removedMasks.forEach(([entityId, component]) => { + if (component?.type === 'mask') { + undoAdds.push({entityId, component}); + } + }); + updateSliceMasks({ + sliceIndex: sidx, + adds: undoAdds, + removes: undoRemoves, + }); + }; + const redo = () => { + updateSliceMasks(input); + }; + pushAction({undo, redo}); }; return {updateSliceMasks}; @@ -620,8 +709,12 @@ export function usePaste(): Pick { const anchors = yjsRectangleAnchorsMap(doc); const masks = yjsMaskMap(doc); + const {deleteComponents} = useAddDeleteComponents(); + const pushAction = useAnnoHistoryStore(s => s.pushAction); const paste = (input: PasteInput) => { const {entityComponents: ecs, sourceSliceIndex: sidx0, targetSliceIndex: sidx1} = input; + const pasted: [EntityId, ComponentId][] = ecs.map(({entityId: eid, newComponentId: cid1}) => [eid, cid1]); + doc.transact(() => { ecs.forEach(({entityId: eid, componentId: cid0, newComponentId: cid1}) => { const comp = comps.get(cid0); @@ -670,6 +763,18 @@ export function usePaste(): Pick { } }); }); + + // history + const undo = () => { + deleteComponents({ + sliceIndex: sidx1, + components: pasted, + }); + }; + const redo = () => { + paste(input); + }; + pushAction({undo, redo}); }; return {paste}; @@ -682,6 +787,7 @@ export function useTranslate(): Pick { const anchors = yjsRectangleAnchorsMap(doc); const masks = yjsMaskMap(doc); + const pushAction = useAnnoHistoryStore(s => s.pushAction); const translate = (input: TranslateInput) => { const {entityComponents: ecs, offsetX: dx, offsetY: dy} = input; @@ -726,6 +832,18 @@ export function useTranslate(): Pick { break; } }); + + const undo = () => { + translate({ + ...input, + offsetX: -input.offsetX, + offsetY: -input.offsetY, + }); + }; + const redo = () => { + translate(input); + }; + pushAction({undo, redo}); }; return {translate};