diff --git a/apps/editor/src/assets/de.json b/apps/editor/src/assets/de.json index 09b969880..04d073ce1 100644 --- a/apps/editor/src/assets/de.json +++ b/apps/editor/src/assets/de.json @@ -93,6 +93,7 @@ "layers": "Ebenen", "main-image-layer": "Haupt-Bildebene", "add-annotation-layer": "Neue Annotation hinzufügen", + "add-annotation-group": "Neue Annotationsgruppe hinzufügen", "no-layers": "Keine Ebenen geladen.", "untitled-layer": "Unbenannte Ebene", "layer-settings": "Ebeneneinstellungen", @@ -124,6 +125,7 @@ "export-tooltip": "Export (Strg + E)", "exporting": "Exportieren", "layers-to-export": "Annotationsebenen, die exportiert werden sollen", + "should-export-images": "Bildebenen mit exportieren", "export-all-layers": "Alle Ebenen exportieren", "export-annotation-group": "Gruppe exportieren", "export-as": "Exportieren als", @@ -439,5 +441,15 @@ "review": "Überprüfen", "supervise": "Abnicken", - "review-description": "{{taskType}} der Annotationen des Bildes {{image}}." + "review-description": "{{taskType}} der Annotationen des Bildes {{image}}.", + + "untitled-group": "Unbenannte Gruppe", + "rename-group": "Gruppe umbenennen", + "delete-group": "Gruppe löschen", + "delete-annotation-group-message": "Wollen Sie die Annotation \"{{name}}\" und die folgenden Ebenen wirklich löschen?", + "warning": "Warnung:", + "delete-backend-data-warning": "Durch das Löschen der Annotationsgruppe \"{{name}}\" wird auch die unter \"{{dataUri}}\" gespeicherte Annotation unwiderruflich gelöscht!", + "unsaved-backend-annotations": "Folgende Gruppen sind noch nicht gespeichert:", + "get-annotation-error": "Annotation konnte nicht gefunden werden", + "get-annotation-error-description": "Die Annotation mit der ID \"{{id}}\" konnte nicht gefunden werden." } diff --git a/apps/editor/src/assets/en.json b/apps/editor/src/assets/en.json index 602299443..ef3c7bc10 100644 --- a/apps/editor/src/assets/en.json +++ b/apps/editor/src/assets/en.json @@ -93,6 +93,7 @@ "layers": "Layers", "main-image-layer": "Main Image Layer", "add-annotation-layer": "Add new annotation", + "add-annotation-group": "Add new annotation group", "no-layers": "No layers loaded.", "untitled-layer": "Untitled layer", "layer-settings": "Layer Settings", @@ -124,6 +125,7 @@ "export-tooltip": "Export (Ctrl/Cmd + E)", "exporting": "Exporting", "layers-to-export": "Layers to export", + "should-export-images": "Include image layers", "export-all-layers": "Export all layers", "export-annotation-group": "Export annotation group", "export-as": "Export as", @@ -438,5 +440,15 @@ "review": "Review", "supervise": "Supervise", - "review-description": "{{taskType}} the annotations of {{image}}" + "review-description": "{{taskType}} the annotations of {{image}}", + + "untitled-group": "Untitled group", + "rename-group": "Rename Group", + "delete-group": "Delete Group", + "delete-annotation-group-message": "Do you really want to delete the annotation group \"{{name}}\" and following layers?", + "warning": "Warning:", + "delete-backend-data-warning": "Deleting the annotation group \"{{name}}\" also deletes the annotation saved under \"{{dataUri}}\"!", + "unsaved-backend-annotations": "The following annotation groups are not saved yet:", + "get-annotation-error": "Annotation cannot be found", + "get-annotation-error-description": "Annotation with the ID \"{{id}}\" cannot be found." } diff --git a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts index cc3e81c07..1117614b0 100644 --- a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts +++ b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts @@ -1,6 +1,8 @@ import type { StatefulPopUpProps } from "@visian/ui-shared"; +import { ReactNode } from "react"; -export interface ConfirmationPopUpProps extends StatefulPopUpProps { +export interface ConfirmationPopUpProps + extends StatefulPopUpProps { title?: string; titleTx?: string; message?: string; @@ -10,4 +12,5 @@ export interface ConfirmationPopUpProps extends StatefulPopUpProps { cancel?: string; cancelTx?: string; onConfirm?: () => void; + children?: ReactNode; } diff --git a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx index 91faa0d6d..3e586e3db 100644 --- a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx +++ b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx @@ -1,6 +1,6 @@ import { ButtonParam, PopUp, Text } from "@visian/ui-shared"; import { observer } from "mobx-react-lite"; -import { useCallback } from "react"; +import { ReactNode, useCallback } from "react"; import styled from "styled-components"; import { ConfirmationPopUpProps } from "./confirmation-popup.props"; @@ -27,7 +27,7 @@ const StyledText = styled(Text)` overflow-wrap: anywhere; `; -export const ConfirmationPopup = observer( +export const ConfirmationPopup = observer>( ({ isOpen, onClose, @@ -40,6 +40,7 @@ export const ConfirmationPopup = observer( confirmTx, cancel, cancelTx, + children, }) => { const handleConfirmation = useCallback(() => { onConfirm?.(); @@ -55,6 +56,7 @@ export const ConfirmationPopup = observer( shouldDismissOnOutsidePress > + {children} ` + width: 18px; + margin-right: 8px; + opacity: ${({ emphasized }) => (emphasized ? 1 : 0.4)}; + transition: opacity 0.1s ease-in-out; +`; + export const ExportPopUp = observer(({ isOpen, onClose }) => { const store = useStore(); @@ -60,31 +71,59 @@ export const ExportPopUp = observer(({ isOpen, onClose }) => { const [selectedExtension, setSelectedExtension] = useState( fileExtensions[0].value, ); + const [shouldIncludeImages, setShouldIncludeImages] = useState(false); useEffect(() => { if (shouldExportAllLayers) { setLayersToExport( store?.editor?.activeDocument?.layers?.filter( - (layer) => layer.isAnnotation, + (layer) => layer.isAnnotation || shouldIncludeImages, ) ?? [], ); + } else if (shouldIncludeImages) { + const activeGroupLayers = + store?.editor?.activeDocument?.activeLayer?.getAnnotationGroupLayers(); + const imageLayers = + store?.editor?.activeDocument?.layers?.filter( + (layer) => !layer.isAnnotation, + ) ?? []; + + const combinedLayers = activeGroupLayers?.concat(imageLayers) ?? []; + setLayersToExport(combinedLayers); } else { setLayersToExport( - store?.editor?.activeDocument?.activeLayer - ?.getAnnotationGroupLayers() - .filter((layer) => layer.isAnnotation) ?? [], + store?.editor?.activeDocument?.activeLayer?.getAnnotationGroupLayers() ?? + [], ); } - }, [store, isOpen, shouldExportAllLayers]); + }, [store, isOpen, shouldExportAllLayers, shouldIncludeImages]); const handleExport = useCallback(async () => { store?.setProgress({ labelTx: "exporting" }); try { + const annotationGroupTitle = + store?.editor?.activeDocument?.activeLayer?.annotationGroup?.title; + + let fileName; + + if (shouldExportAllLayers) { + fileName = undefined; + } else { + fileName = annotationGroupTitle + ? path.basename( + annotationGroupTitle, + path.extname(annotationGroupTitle), + ) + : undefined; + } if (selectedExtension === ".zip") { - await store?.editor.activeDocument?.exportZip(layersToExport, true); + await store?.editor.activeDocument?.exportZip(layersToExport, fileName); } else { - await store?.editor?.activeDocument?.exportSquashedNii(layersToExport); + await store?.editor?.activeDocument?.exportSquashedNii( + layersToExport, + fileName, + ); } } catch (error) { store?.setError({ @@ -94,7 +133,36 @@ export const ExportPopUp = observer(({ isOpen, onClose }) => { } finally { store?.setProgress(); } - }, [layersToExport, selectedExtension, store]); + }, [layersToExport, selectedExtension, shouldExportAllLayers, store]); + + const handleCheckIncludeImageLayer = useCallback( + (value: boolean) => { + if (value) { + const imageLayers = + store?.editor?.activeDocument?.layers?.filter( + (layer) => !layer.isAnnotation, + ) ?? []; + const newLayersToExport = layersToExport.concat(imageLayers); + setLayersToExport(newLayersToExport); + setShouldIncludeImages(true); + } else { + setLayersToExport(layersToExport.filter((layer) => layer.isAnnotation)); + setShouldIncludeImages(false); + } + }, + [layersToExport, store?.editor?.activeDocument?.layers], + ); + + const handleSelectExtension = useCallback((value: string) => { + if (value === ".nii.gz") { + setLayersToExport( + store?.editor?.activeDocument?.activeLayer?.getAnnotationGroupLayers() ?? + [], + ); + setShouldIncludeImages(false); + } + setSelectedExtension(value); + }, []); return ( (({ isOpen, onClose }) => { value={shouldExportAllLayers} onChange={setShouldExportAllLayers} /> + {selectedExtension === ".zip" && ( + + + + handleCheckIncludeImageLayer(!shouldIncludeImages) + } + emphasized={shouldIncludeImages} + /> + + )} setSelectedExtension(value)} + onChange={(value) => handleSelectExtension(value)} size="medium" borderRadius="default" /> diff --git a/apps/editor/src/components/editor/layers/draggable-group-list-item.tsx b/apps/editor/src/components/editor/layers/draggable-group-list-item.tsx new file mode 100644 index 000000000..50d313d2d --- /dev/null +++ b/apps/editor/src/components/editor/layers/draggable-group-list-item.tsx @@ -0,0 +1,46 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { IAnnotationGroup, ILayer } from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import styled from "styled-components"; + +import { AnnotationGroupListItem } from "./group-list-item"; + +const DraggableDiv = styled.div<{ + opacity: number; + transform: string | undefined; + transition: string | undefined; +}>` + opacity: ${({ opacity }) => opacity}; + transform: ${({ transform }) => transform}; + transition: ${({ transition }) => transition}; +`; + +export const DraggableAnnotationGroupListItem = observer<{ + group: IAnnotationGroup; + isActive: boolean; + isLast?: boolean; + isDragged?: boolean; + draggedLayer?: ILayer; +}>(({ group, isActive, isLast, isDragged, draggedLayer }) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: group.id, data: { annotationGroup: group } }); + + return ( + + + + ); +}); diff --git a/apps/editor/src/components/editor/layers/draggable-layer-list-item.tsx b/apps/editor/src/components/editor/layers/draggable-layer-list-item.tsx new file mode 100644 index 000000000..5cf988590 --- /dev/null +++ b/apps/editor/src/components/editor/layers/draggable-layer-list-item.tsx @@ -0,0 +1,43 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ILayer } from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import styled from "styled-components"; + +import { LayerListItem } from "./layer-list-item"; + +const DraggableDiv = styled.div<{ + opacity: number; + transform: string | undefined; + transition: string | undefined; +}>` + opacity: ${({ opacity }) => opacity}; + transform: ${({ transform }) => transform}; + transition: ${({ transition }) => transition}; +`; + +export const DraggableLayerListItem = observer<{ + layer: ILayer; + isActive?: boolean; + isLast?: boolean; + isDragged?: boolean; +}>(({ layer, isActive, isLast, isDragged }) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ + id: layer.id, + data: { layer }, + }); + + return ( + + + + ); +}); diff --git a/apps/editor/src/components/editor/layers/group-list-item.tsx b/apps/editor/src/components/editor/layers/group-list-item.tsx index 68e6ca03a..f737c2830 100644 --- a/apps/editor/src/components/editor/layers/group-list-item.tsx +++ b/apps/editor/src/components/editor/layers/group-list-item.tsx @@ -1,23 +1,267 @@ -import { FullWidthListItem, IAnnotationGroup } from "@visian/ui-shared"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { + ContextMenu, + ContextMenuItem, + FullWidthListItem, + IAnnotationGroup, + ILayer, + LayerList, + List, + ListItem, + PointerButton, + Text, + useDoubleTap, + useForwardEvent, + useTranslation, +} from "@visian/ui-shared"; +import { isMiaAnnotationMetadata, MiaAnnotation, Pixel } from "@visian/utils"; import { observer } from "mobx-react-lite"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { + getAnnotation, + useDeleteAnnotationsForImageMutation, +} from "../../../queries"; +import { ConfirmationPopup, usePopUpState } from "../../data-manager"; +import { DraggableLayerListItem } from "./draggable-layer-list-item"; + +const ChildLayerContainer = styled.div` + margin-left: 16px; +`; + +const LayerListContainer = styled.div` + padding: 16px; +`; + +const UnsavedGroupList = styled(List)` + margin: 5px 0; +`; + +const UnsavedGroupListItem = styled(ListItem)` + margin: -4px 0; +`; + +const StyledText = styled(Text)<{ marginBottom?: number; marginTop?: number }>` + margin-bottom: ${({ marginBottom }) => marginBottom || "2"}px; + margin-top: ${({ marginTop }) => marginTop || "2"}px; +`; export const AnnotationGroupListItem = observer<{ group: IAnnotationGroup; isActive: boolean; isLast?: boolean; -}>(({ group, isActive, isLast }) => { + draggedLayer?: ILayer; +}>(({ group, isActive, isLast, draggedLayer }) => { + const store = useStore(); + + const { t } = useTranslation(); + const toggleCollapse = useCallback(() => { - group.collapsed = !group.collapsed; + group.setCollapsed(!group.collapsed); }, [group]); + + const hideDividerForLayer = useCallback( + (layerIndex: number) => { + if (isLast && layerIndex === group.layers.length - 1) return true; + const indexOfActiveLayer = group.layers.findIndex((l) => l.isActive); + return layerIndex === indexOfActiveLayer - 1; + }, + [group, isLast], + ); + + const startTap2 = useDoubleTap( + useCallback((event: React.PointerEvent) => { + if (event.pointerType === "mouse") return; + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }, []), + ); + const startTap = useForwardEvent(startTap2); + + // Context Menu + const [contextMenuPosition, setContextMenuPosition] = useState( + null, + ); + const closeContextMenu = useCallback(() => { + setContextMenuPosition(null); + }, []); + useEffect(() => { + setContextMenuPosition(null); + }, [store?.editor.activeDocument?.viewSettings.viewMode]); + + // Press Handler + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (event.button === PointerButton.LMB) { + startTap(event); + } else if (event.button === PointerButton.RMB) { + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + } + }, + [startTap], + ); + + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + // Layer Renaming Handling + const [isAnnotationGroupNameEditable, setIsAnnotationGroupNameEditable] = + useState(false); + const startEditingAnnotationGroupName = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + setIsAnnotationGroupNameEditable(true); + closeContextMenu(); + }, + [closeContextMenu], + ); + const stopEditingAnnotationGroupName = useCallback(() => { + setIsAnnotationGroupNameEditable(false); + }, []); + + // Delete annotation confirmation popup + const [ + isDeleteConfirmationPopUpOpen, + openDeleteConfirmationPopUp, + closeDeleteConfirmationPopUp, + ] = usePopUpState(false); + + const { deleteAnnotations } = useDeleteAnnotationsForImageMutation(); + const [miaAnnotationToBeDeleted, setMiaAnnotationToBeDeleted] = + useState(); + + // Delete is not yet implemented for WHO + const deleteAnnotationGroup = useCallback(async () => { + if (group.metadata && isMiaAnnotationMetadata(group.metadata)) { + try { + const miaAnnotation = await getAnnotation(group.metadata.id); + setMiaAnnotationToBeDeleted(miaAnnotation); + openDeleteConfirmationPopUp(); + } catch (error) { + store?.setError({ + titleTx: "get-annotation-error", + description: t("get-annotation-error-description", { + id: miaAnnotationToBeDeleted?.id, + }), + }); + } + } else if (!group.metadata) { + group.delete(); + setContextMenuPosition(null); + } + }, [ + group, + miaAnnotationToBeDeleted?.id, + openDeleteConfirmationPopUp, + store, + t, + ]); + + const handleDeletionConfirmation = useCallback(() => { + if (miaAnnotationToBeDeleted) { + deleteAnnotations({ + imageId: miaAnnotationToBeDeleted?.image, + annotationIds: [miaAnnotationToBeDeleted?.id], + }); + setMiaAnnotationToBeDeleted(undefined); + } + group.delete(); + setContextMenuPosition(null); + }, [deleteAnnotations, group, miaAnnotationToBeDeleted]); + return ( - + <> + + {!group.collapsed && ( + + + {group.layers.map((layer, index) => ( + + ))} + + + )} + + + + + + + + + {group.metadata && ( + <> + + + {store?.editor?.activeDocument?.annotationGroups.filter( + (g) => g.metadata?.id && g.hasChanges, + ).length !== 0 && ( + <> + + + {store?.editor?.activeDocument?.annotationGroups + .filter((g) => g.metadata?.id && g.hasChanges) + ?.map((groupToSave) => ( + + {`• ${groupToSave.title}`} + + ))} + + + )} + + )} + + ); }); diff --git a/apps/editor/src/components/editor/layers/layer-list-item.tsx b/apps/editor/src/components/editor/layers/layer-list-item.tsx index 6dd06fae1..652e172c1 100644 --- a/apps/editor/src/components/editor/layers/layer-list-item.tsx +++ b/apps/editor/src/components/editor/layers/layer-list-item.tsx @@ -135,6 +135,7 @@ export const LayerListItem = observer<{ }, [layer, store]); const deleteLayer = useCallback(() => { + layer.annotationGroup?.setHasUnsavedChanges(true); if ( // eslint-disable-next-line no-alert window.confirm(t("delete-layer-confirmation", { layer: layer.title })) diff --git a/apps/editor/src/components/editor/layers/layers.tsx b/apps/editor/src/components/editor/layers/layers.tsx index ede28cc9e..fe5171d8a 100644 --- a/apps/editor/src/components/editor/layers/layers.tsx +++ b/apps/editor/src/components/editor/layers/layers.tsx @@ -1,3 +1,21 @@ +import { + CollisionDetection, + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + pointerWithin, + rectIntersection, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { computeStyleValue, FloatingUIButton, @@ -13,22 +31,18 @@ import { styledScrollbarMixin, SubtleText, } from "@visian/ui-shared"; -import { - SimpleTreeItemWrapper, - SortableTree, - TreeItem, - TreeItemComponentProps, - TreeItems, -} from "dnd-kit-sortable-tree"; -import { ItemChangedReason } from "dnd-kit-sortable-tree/dist/types"; +import { transaction } from "mobx"; import { observer } from "mobx-react-lite"; -import React, { ReactNode, useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import styled from "styled-components"; import { useStore } from "../../../app/root-store"; import { ImageLayer } from "../../../models"; import { AnnotationGroup } from "../../../models/editor/annotation-groups"; import { InfoShortcuts } from "../info-shortcuts"; +import { DraggableAnnotationGroupListItem } from "./draggable-group-list-item"; +import { DraggableLayerListItem } from "./draggable-layer-list-item"; import { AnnotationGroupListItem } from "./group-list-item"; import { LayerListItem } from "./layer-list-item"; @@ -41,7 +55,7 @@ const OuterWrapper = styled("div")` width: 100%; `; -const LayerList = styled(List)` +const StyledLayerList = styled(List)` ${styledScrollbarMixin} margin-top: -16px; @@ -66,140 +80,40 @@ const LayerModal = styled(Modal)` justify-content: center; `; -interface TreeItemStyleWrapperProps - extends TreeItemComponentProps { - passedRef: React.ForwardedRef; - children: ReactNode; -} - -const TreeItemStyleWrapper = (props: TreeItemStyleWrapperProps) => { - const { passedRef, className, ...restProps } = props; - const newProps = { contentClassName: className, ...restProps }; - return ( - - ); -}; - -const indentationWidth = 20; -const treeWidth = 235; - -const StyledTreeItem = styled(TreeItemStyleWrapper)` - padding: 0px; - border: none; - background: none; - width: 100%; - max-width: ${treeWidth}px; +const StyledModalHeaderButton = styled(ModalHeaderButton)` + margin-right: 10px; `; -type TreeItemData = { - value: string; -}; +const customCollisionDetection: CollisionDetection = (args) => { + const activeLayer = args.active.data.current?.layer as ILayer; + if (!activeLayer) return rectIntersection(args); -const LayerItem = ({ id }: { id: string }) => { - const store = useStore(); - const hideLayerDivider = useCallback( - (element: ILayer | IAnnotationGroup) => { - if (!element) return false; - const flatRenderingOrder = - store?.editor.activeDocument?.flatRenderingOrder; - if (!flatRenderingOrder) return false; - const renderingOrder = store?.editor.activeDocument?.renderingOrder; - if (!renderingOrder) return false; - const layerIndex = flatRenderingOrder.indexOf(element); - if (layerIndex === flatRenderingOrder.length - 1) return true; - if ( - element instanceof AnnotationGroup && - element.collapsed && - renderingOrder.indexOf(element) === renderingOrder.length - 1 - ) { - return true; - } - const nextElement = flatRenderingOrder[layerIndex + 1]; - return ( - nextElement.isActive && - (nextElement instanceof AnnotationGroup ? nextElement.collapsed : true) - ); - }, - [ - store?.editor.activeDocument?.flatRenderingOrder, - store?.editor.activeDocument?.renderingOrder, - ], - ); + // Check if there is a collission with a group different from the + // one of the dragged layer: + const pointerCollisions = pointerWithin(args); + const groupCollission = pointerCollisions.find((collission) => { + const group = collission.data?.droppableContainer.data.current + .annotationGroup as IAnnotationGroup; + return group && group !== activeLayer.annotationGroup; + }); + if (groupCollission) return [groupCollission]; - const group = store?.editor.activeDocument?.getAnnotationGroup(id); - if (group) { - const isActive = !!group.collapsed && group.isActive; - return ( -
- -
- ); - } - const layer = store?.editor.activeDocument?.getLayer(id); - if (layer) { - return ( -
- -
- ); - } - return ( - - - - ); + return pointerWithin(args); }; -const TreeItemComponent = React.forwardRef< - HTMLDivElement, - TreeItemComponentProps ->((props, ref) => ( - - - -)); - -const layerToTreeItemData = (layer: ILayer) => ({ - id: layer.id, - value: layer.id, - canHaveChildren: false, -}); - export const Layers: React.FC = observer(() => { const store = useStore(); + const document = store?.editor.activeDocument; // Menu State - const isModalOpen = Boolean(store?.editor.activeDocument?.showLayerMenu); + const isModalOpen = Boolean(document?.showLayerMenu); // Menu Positioning const [buttonRef, setButtonRef] = useState(null); // This is required to force an update when the view mode changes // (otherwise the layer menu stays fixed in place when switching the view mode) - const viewMode = store?.editor.activeDocument?.viewSettings.viewMode; + const viewMode = document?.viewSettings.viewMode; const [, setLastUpdatedViewMode] = useState(); useEffect(() => { setTimeout(() => { @@ -207,120 +121,173 @@ export const Layers: React.FC = observer(() => { }, 50); }, [viewMode]); - // This is required to force an update when the active layer changes and the layer view must change its position - // (otherwise the layer menu stays fixed in place when switching the active layer between image and annotation) - // eslint-disable-next-line no-unused-expressions, @typescript-eslint/no-unused-expressions - store?.editor.activeDocument?.activeLayer; + const layers = document?.layers; + const layerIds = useMemo( + () => document?.renderingOrder.map((element) => element.id) || [], + [document?.renderingOrder], + ); - const layers = store?.editor.activeDocument?.layers; - const canGroupHaveChildren = useCallback( - (group: IAnnotationGroup) => { - const callback = (draggedItem: TreeItem) => { - const layer = store?.editor.activeDocument?.getLayer(draggedItem.value); - if (store?.reviewStrategy && layer && !group.layers.includes(layer)) { - return false; - } + const [draggedLayer, setDraggedLayer] = useState(); + const [draggedGroup, setDraggedGroup] = useState(); + const [ + draggedLayerPreviousAnnotationGroup, + setDraggedLayerPreviousAnnotationGroup, + ] = useState(); - return !!layer; - }; - return callback; - }, - [store?.editor.activeDocument, store?.reviewStrategy], + const dndSensors = useSensors( + // Require the mouse to move before dragging so we capture normal clicks: + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), ); - const getTreeItems = useCallback(() => { - const annotationGroupToTreeItemData = (group: IAnnotationGroup) => ({ - id: group.id, - value: group.id, - children: group.layers.map((layer) => layerToTreeItemData(layer)), - collapsed: group.collapsed, - canHaveChildren: canGroupHaveChildren(group), - disableSorting: false, - }); - - const renderingOrder = store?.editor.activeDocument?.renderingOrder; - if (!renderingOrder) { - return []; + // This handler is called when the user starts dragging a layer or group: + const dndDragStart = useCallback((event: DragStartEvent) => { + if (event.active.data.current?.layer) { + setDraggedLayer(event.active.data.current?.layer); + setDraggedLayerPreviousAnnotationGroup( + event.active.data.current.layer.annotationGroup, + ); + } else if (event.active.data.current?.annotationGroup) { + setDraggedGroup(event.active.data.current?.annotationGroup); } + }, []); - return renderingOrder.map((element) => { - if (element instanceof AnnotationGroup) { - const group = element as AnnotationGroup; - return annotationGroupToTreeItemData(group); - } - if (element instanceof ImageLayer) { - const layer = element as ImageLayer; - return layerToTreeItemData(layer); - } - return { id: "undefined", value: "undefined" }; - }); - }, [canGroupHaveChildren, store?.editor.activeDocument?.renderingOrder]); - - const treeItems = getTreeItems(); + // This handler is called when the user currently drags a layer or group. + // Be aware that the value in event.over is influenced by our custom + // collission detection stragegy. + const dndDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeLayer = active.data.current?.layer as ILayer; + // Return if we are not dragging a layer: + if (!activeLayer) return; + // Return if we are dragging image layer + if (!activeLayer.isAnnotation) return; + // Return if we are not dragging OVER a + // group or if we are dragging over the layer's own group: + const overGroup = over.data.current?.annotationGroup as IAnnotationGroup; + if (!overGroup) return; + if (activeLayer.annotationGroup?.id === overGroup.id) return; - const canRootHaveChildren = useCallback((item) => { - const layer = store?.editor.activeDocument?.getLayer(item.value); - if (!layer) return true; // layerFamilies can always be children of root - return layer.annotationGroup === undefined; - }, []); - const updateRenderingOrder = useCallback( - ( - newTreeItems: TreeItems, - change: ItemChangedReason, - ) => { - if (change.type === "removed") return; - if (change.type === "collapsed" || change.type === "expanded") { - const group = store?.editor.activeDocument?.getAnnotationGroup( - change.item.value, + // Move layer from its old group to the new one. + // Use a transaction to make sure that mobx only updates + // dependencies once we have completed the move: + transaction(() => { + (activeLayer.annotationGroup as AnnotationGroup)?.removeLayer( + activeLayer, ); - if (!group) return; - group.collapsed = change.item.collapsed; + if (!activeLayer.annotationGroup && document) { + document.removeLayerFromRootList(activeLayer); + } + (overGroup as AnnotationGroup).addLayer(activeLayer); + }); + }, + [document], + ); + + // This handler is called when the user lets go of a layer or group after dragging: + const dndDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!document || !over || active.id === over.id) { + setDraggedGroup(undefined); + setDraggedLayer(undefined); return; } - if (change.type === "dropped") { - const draggedLayer = store?.editor.activeDocument?.getLayer( - change.draggedItem.value, + + const dragged = active.data.current; + if (dragged?.annotationGroup) { + const group = dragged?.annotationGroup as IAnnotationGroup; + const oldIndex = document.renderingOrder.indexOf(group); + const newIndex = document.renderingOrder.indexOf( + over.data?.current?.annotationGroup, ); + if (newIndex !== -1) { + const newLayerIds = arrayMove( + document.renderingOrder.map((item) => item.id), + oldIndex, + newIndex, + ); + document.setLayerIds(newLayerIds); + } + } else if (dragged?.layer) { + const layer = dragged?.layer as ILayer; + // Only re-sort the groups layers if + // - the current layer is in a group + // - the group has more than 1 layer + // - we are not just dragging into the group folder, but actually into the layers of the group if ( - store?.reviewStrategy && - draggedLayer && - (change.draggedFromParent - ? change.draggedFromParent.value - : undefined) !== - (change.droppedToParent ? change.droppedToParent.value : undefined) + layer.annotationGroup && + layer.annotationGroup.layers.length > 1 && + over.data?.current?.layer ) { - return; - } - newTreeItems.forEach((item, index) => { - const layer = store?.editor.activeDocument?.getLayer(item.value); - if (layer) { - layer?.setAnnotationGroup(undefined, index); - } - const group = store?.editor.activeDocument?.getAnnotationGroup( - item.value, + const oldIndex = layer.annotationGroup.layerIds.indexOf(layer.id); + const newIndex = layer.annotationGroup.layerIds.indexOf( + over.data.current.layer.id, ); - if (group) { - item.children?.forEach((childItem, childIndex) => { - const childLayer = store?.editor.activeDocument?.getLayer( - childItem.value, - ); - if (childLayer) { - childLayer.setAnnotationGroup(group.id, childIndex); - } - }); - group.collapsed = item.collapsed; - store?.editor.activeDocument?.addAnnotationGroup( - group as AnnotationGroup, - index, - ); + const newLayerIds = arrayMove( + layer.annotationGroup.layerIds, + oldIndex, + newIndex, + ); + layer.annotationGroup.setLayerIds(newLayerIds); + if ( + draggedLayerPreviousAnnotationGroup?.id !== + over.data.current.layer.annotationGroup.id + ) { + draggedLayerPreviousAnnotationGroup?.setHasUnsavedChanges(true); + over.data.current.layer.annotationGroup.setHasUnsavedChanges(true); } - }); + } + // Prevents dragging an image layer within or between respectivly above annotation groups. + else if (!layer.annotationGroup && !layer.isAnnotation && layers) { + const oldIndex = layerIds.indexOf(layer.id); + const newIndex = layerIds.indexOf(over.data?.current?.layer?.id); + if (dragged?.layer && newIndex !== -1) { + const newLayerIds = arrayMove(layerIds, oldIndex, newIndex); + document.setLayerIds(newLayerIds); + } + } } + setDraggedLayer(undefined); + setDraggedGroup(undefined); + setDraggedLayerPreviousAnnotationGroup(undefined); }, - [store?.editor.activeDocument, store?.reviewStrategy], + [document, draggedLayerPreviousAnnotationGroup, layerIds, layers], ); - const firstElement = store?.editor.activeDocument?.renderingOrder[0]; + const listItems = document?.renderingOrder.map((element, index) => { + if (element instanceof AnnotationGroup) { + const group = element as AnnotationGroup; + return ( + + ); + } + const layer = element as ImageLayer; + return ( + + ); + }); + + const firstElement = document?.renderingOrder[0]; const isHeaderDivideVisible = !( firstElement?.isActive && (firstElement instanceof AnnotationGroup ? firstElement.collapsed : true) @@ -334,6 +301,11 @@ export const Layers: React.FC = observer(() => { ); } + const handleAddLayer = useCallback(() => { + document?.addNewAnnotationLayer(); + document?.activeLayer?.annotationGroup?.setHasUnsavedChanges(true); + }, [document]); + return ( <> { tooltipTx="layers" showTooltip={!isModalOpen} ref={setButtonRef} - onPointerDown={store?.editor.activeDocument?.toggleLayerMenu} + onPointerDown={document?.toggleLayerMenu} isActive={isModalOpen} /> { } /> + {document.activeLayer?.annotationGroup && ( + = + (document?.maxVisibleLayers || 0) + } + onPointerDown={() => handleAddLayer()} + /> + )} = - (store?.editor.activeDocument?.maxVisibleLayers || 0) - } - onPointerDown={ - store?.editor.activeDocument?.addNewAnnotationLayer + !document?.imageLayers?.length || + document?.imageLayers?.length >= + (document?.maxVisibleLayers || 0) } + onPointerDown={document?.addNewAnnotationGroup} /> } > - - false }} - indentationWidth={20} - /> - {layers.length === 0 ? ( - - - - ) : ( - false + + + + {listItems} + {layers.length === 0 ? ( + + + + ) : ( + false + )} + + + {createPortal( + + {draggedLayer ? ( + + ) : null} + {draggedGroup ? ( + + ) : null} + , + window.document.body, )} - + diff --git a/apps/editor/src/components/editor/review-bar/review-bar.tsx b/apps/editor/src/components/editor/review-bar/review-bar.tsx index 7c4b6de08..5c84d98a4 100644 --- a/apps/editor/src/components/editor/review-bar/review-bar.tsx +++ b/apps/editor/src/components/editor/review-bar/review-bar.tsx @@ -340,7 +340,7 @@ export const MiaReviewBar = observer( const annotationGroupTitles = store?.editor.activeDocument?.annotationGroups.map( - (group) => group.title, + (group) => group.title || t("untitled-group"), ); const onGroupSwitch = (newIndex: number) => { diff --git a/apps/editor/src/components/editor/save-popup/save-popup.tsx b/apps/editor/src/components/editor/save-popup/save-popup.tsx index e78ba9a58..20334d7d7 100644 --- a/apps/editor/src/components/editor/save-popup/save-popup.tsx +++ b/apps/editor/src/components/editor/save-popup/save-popup.tsx @@ -1,6 +1,7 @@ import { Button, DropDown, + IAnnotationGroup, ILayer, LayerList, PopUp, @@ -22,10 +23,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { useStore } from "../../../app/root-store"; -import { importFilesToDocument } from "../../../import-handling"; -import { AnnotationGroup } from "../../../models/editor/annotation-groups"; import { MiaReviewTask } from "../../../models/review-strategy"; -import { fetchAnnotationFile } from "../../../queries"; import { SavePopUpProps } from "./save-popup.props"; const SectionLabel = styled(Text)` @@ -91,28 +89,28 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { [newAnnotationURIPrefix, selectedExtension], ); - const createGroupForNewAnnotation = ( - layer: ILayer | undefined, - annotation: MiaAnnotation | undefined, + const changeMetaDataForGroup = ( + annotationGroup: IAnnotationGroup | undefined, + annotationId: string, + uri: string, ) => { const document = store?.editor.activeDocument; - if (document && layer) { - const annotationGroup = new AnnotationGroup(undefined, document); - document.addAnnotationGroup(annotationGroup); - if (annotation) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - annotationGroup.title = - annotation.dataUri.split("/").pop() ?? "annotation group :)"; - annotationGroup.metadata = { - ...annotation, + if (document && annotationGroup && annotationId) { + annotationGroup.metadata = { + id: annotationId, + backend: "mia", + kind: "annotation", + }; + annotationGroup.layers.forEach((l) => { + l.metadata = { + id: annotationId, + dataUri: uri, backend: "mia", kind: "annotation", }; - } - const groupLayers = layer.getAnnotationGroupLayers(); - groupLayers.forEach((l) => annotationGroup.addLayer(l.id)); - return annotationGroup; + }); } + return annotationGroup; }; const createFileForAnnotationGroupOf = async ( @@ -138,14 +136,6 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { } }; - const importAnnotationFile = async (annotationFile: FileWithMetadata) => { - const fileTransfer = new DataTransfer(); - fileTransfer.items.add(annotationFile); - if (store) { - await importFilesToDocument(fileTransfer.files, store); - } - }; - const canBeOverwritten = useCallback(() => { const activeLayer = store?.editor.activeDocument?.activeLayer; const annotation = activeLayer?.annotationGroup?.metadata as MiaAnnotation; @@ -173,6 +163,8 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { activeLayer?.getAnnotationGroupLayers().forEach((layer) => { store?.editor.activeDocument?.history?.updateCheckpoint(layer.id); }); + // Reset the layer unsaved changes flag + activeLayer?.annotationGroup?.setHasUnsavedChanges(false); return true; } catch (error) { if (error instanceof AxiosError) { @@ -192,36 +184,6 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { } }; - const loadOldAnnotation = async ( - newAnnotationMeta: MiaAnnotation, - oldAnnotationMeta: MiaAnnotation, - ) => { - const activeLayer = store?.editor.activeDocument?.activeLayer; - if (activeLayer?.annotationGroup) { - activeLayer.annotationGroup.metadata = { - ...newAnnotationMeta, - backend: "mia", - kind: "annotation", - }; - activeLayer.annotationGroup.title = newAnnotationMeta.dataUri; - activeLayer.annotationGroup.layers.forEach((layer, index) => { - layer.metadata = { - ...newAnnotationMeta, - backend: "mia", - kind: "annotation", - }; - layer.setTitle( - `${index + 1}_${newAnnotationMeta.dataUri.split("/").pop()}`, - ); - }); - } - const oldAnnotationFile = await fetchAnnotationFile(oldAnnotationMeta.id); - await importAnnotationFile(oldAnnotationFile); - if (activeLayer) { - store?.editor.activeDocument?.setActiveLayer(activeLayer.id); - } - }; - const saveAnnotationAs = async (uri: string) => { store?.setProgress({ labelTx: "saving" }); try { @@ -243,21 +205,18 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { const newAnnotationId = await reviewTask.createAnnotation([ annotationFile, ]); - const annotationMeta = activeLayer?.annotationGroup - ?.metadata as MiaAnnotation; - const newAnnotationMeta = - reviewTask instanceof MiaReviewTask - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - reviewTask.getAnnotation(newAnnotationId)! - : annotationMeta; - if (!annotationMeta) { - createGroupForNewAnnotation(activeLayer, newAnnotationMeta); - } else { - await loadOldAnnotation(newAnnotationMeta, annotationMeta); + if (reviewTask instanceof MiaReviewTask) { + changeMetaDataForGroup( + activeLayer?.annotationGroup, + newAnnotationId, + uri, + ); + activeLayer?.getAnnotationGroupLayers().forEach((layer) => { + store?.editor.activeDocument?.history?.updateCheckpoint(layer.id); + }); } - activeLayer?.getAnnotationGroupLayers().forEach((layer) => { - store?.editor.activeDocument?.history?.updateCheckpoint(layer.id); - }); + // Reset the layer count changes flag + activeLayer?.annotationGroup?.setHasUnsavedChanges(false); store?.setProgress(); return true; } catch (error) { @@ -287,23 +246,11 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { const mainImageLayerMetadata = store?.editor.activeDocument?.mainImageLayer?.metadata; const imageName = getImageName(mainImageLayerMetadata); - const layerName = - store?.editor.activeDocument?.activeLayer?.title?.split(".")[0]; - const layerNameWithoutIndex = Number.isNaN( - +(layerName?.split("_")[0] ?? ""), - ) - ? layerName - : layerName?.split("_").slice(1).join("_"); - const layerNameWithIndexedName = Number.isNaN( - +(layerNameWithoutIndex?.split("_")[0] ?? ""), - ) - ? `1_${layerNameWithoutIndex}` - : layerNameWithoutIndex - ?.split("_") - .map((sub, index) => (index === 0 ? Number(sub) + 1 : sub)) - .join("_"); + const groupName = + store?.editor.activeDocument?.activeLayer?.annotationGroup?.title; + const groupNameWithUnderscores = groupName?.replace(/\s/g, "_"); return `/annotations/${imageName}/${ - layerNameWithIndexedName || "annotation" + groupNameWithUnderscores || "annotation" }`; }, [store]); @@ -323,7 +270,7 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { return pattern.test(dataUri) ? "valid" - : translate("data_uri_help_message"); + : translate("data-uri-help-message"); }, [translate], ); @@ -333,6 +280,17 @@ export const SavePopUp = observer(({ isOpen, onClose }) => { [newDataUri, isValidDataUri], ); + const handleRenameUri = useCallback( + (newUriPrefix: string) => { + setnewAnnotationURIPrefix(newUriPrefix); + const newGroupName = path.basename(newUriPrefix); + store?.editor?.activeDocument?.activeLayer?.annotationGroup?.setTitle( + newGroupName, + ); + }, + [store?.editor?.activeDocument?.activeLayer?.annotationGroup], + ); + return ( (({ isOpen, onClose }) => { )} - {isValidAnnotationUri !== "valid" && } + {isValidAnnotationUri !== "valid" && ( + + )} { store.setProgress({ labelTx: "exporting" }); store.editor.activeDocument - ?.exportZip(store.editor.activeDocument.layers, true) + ?.exportZip( + store.editor.activeDocument.layers, + store.editor.activeDocument.activeLayer?.annotationGroup?.title, + ) .catch() .then(() => { store?.setProgress(); diff --git a/apps/editor/src/models/editor/annotation-groups/annotation-group.ts b/apps/editor/src/models/editor/annotation-groups/annotation-group.ts index f08b7cfbc..575967f14 100644 --- a/apps/editor/src/models/editor/annotation-groups/annotation-group.ts +++ b/apps/editor/src/models/editor/annotation-groups/annotation-group.ts @@ -8,20 +8,29 @@ import { ISerializable, isMiaAnnotationMetadata, } from "@visian/utils"; -import { action, computed, makeObservable, observable } from "mobx"; +import { + action, + computed, + makeObservable, + observable, + transaction, +} from "mobx"; import { v4 as uuidv4 } from "uuid"; import { Document } from "../document"; +import { ImageLayer } from "../layers"; export class AnnotationGroup implements IAnnotationGroup, ISerializable { + public layerIds: string[] = []; public excludeFromSnapshotTracking = ["document"]; - protected layerIds: string[] = []; - public title = ""; public id!: string; + protected titleOverride?: string; + public collapsed?: boolean; public metadata?: BackendMetadata; + protected hasUnsavedChanges = false; constructor( snapshot: Partial | undefined, @@ -29,13 +38,22 @@ export class AnnotationGroup ) { this.applySnapshot(snapshot); - makeObservable(this, { + makeObservable< + this, + "hasUnsavedChanges" | "titleOverride" | "layerIds" | "metadata" + >(this, { layerIds: observable, collapsed: observable, - title: observable, + titleOverride: observable, + hasUnsavedChanges: observable, isActive: computed, + hasChanges: computed, metadata: observable, + setTitle: action, + setCollapsed: action, + setHasUnsavedChanges: action, + setLayerIds: action, addLayer: action, removeLayer: action, trySetIsVerified: action, @@ -47,32 +65,44 @@ export class AnnotationGroup } public get hasChanges() { - return this.layers.some((layer) => layer.hasChanges); + const hasChangesInLayers = this.layers.some( + (layer) => layer.kind === "image" && (layer as ImageLayer).hasChanges, + ); + return hasChangesInLayers || this.hasUnsavedChanges; } - public addLayer(id: string, index?: number) { - const layer = this.document.getLayer(id); - if (!layer) return; - if (layer.annotationGroup !== this) { - layer.annotationGroup?.removeLayer(layer.id); - } - const oldIndex = this.layerIds.indexOf(layer.id); - if (oldIndex < 0 && index !== undefined) { - this.layerIds.splice(index, 0, layer.id); - } else if (oldIndex < 0 && index === undefined) { - this.layerIds.push(id); - } else if (index !== undefined && oldIndex !== index) { - this.layerIds.splice(index, 0, this.layerIds.splice(oldIndex, 1)[0]); - } - this.document.addLayer(layer, index); + public get title(): string | undefined { + return this.titleOverride; + } + + public setTitle = (value?: string): void => { + this.titleOverride = value; + }; + + public setCollapsed(value: boolean) { + this.collapsed = value; + } + + public setHasUnsavedChanges(value: boolean): void { + this.hasUnsavedChanges = value; } - public removeLayer(id: string, index?: number) { - if (!this.layerIds.includes(id)) return; - this.layerIds = this.layerIds.filter((layerId) => layerId !== id); - const layer = this.document.getLayer(id); - if (!layer) return; - this.document.addLayer(layer, index); + public setLayerIds(ids: string[]) { + this.layerIds = ids; + } + + public addLayer(layer: ILayer) { + if (!layer.isAnnotation) return; + transaction(() => { + this.setLayerIds([...this.layerIds, layer.id]); + // In case the layer was in the document layer list (i.e. not in a group) + // we also remove it from there: + this.document.removeLayerFromRootList(layer); + }); + } + + public removeLayer(layer: ILayer) { + this.setLayerIds(this.layerIds.filter((layerId) => layerId !== layer.id)); } public get isActive() { @@ -85,7 +115,7 @@ export class AnnotationGroup public toJSON(): AnnotationGroupSnapshot { return { id: this.id, - title: this.title, + titleOverride: this.titleOverride, metadata: this.metadata ? { ...this.metadata } : undefined, layerIds: [...this.layerIds], }; @@ -96,7 +126,7 @@ export class AnnotationGroup ) { if (!snapshot) return; this.id = snapshot.id || uuidv4(); - this.title = snapshot.title || ""; + this.setTitle(snapshot?.titleOverride || ""); this.metadata = snapshot.metadata ? { ...snapshot.metadata } : undefined; this.layerIds = snapshot.layerIds || []; } @@ -109,4 +139,10 @@ export class AnnotationGroup }; } } + + public delete() { + if (this.document.annotationGroups.includes(this)) { + this.document.deleteAnnotationGroup(this); + } + } } diff --git a/apps/editor/src/models/editor/document.ts b/apps/editor/src/models/editor/document.ts index bec43d800..ada02a9da 100644 --- a/apps/editor/src/models/editor/document.ts +++ b/apps/editor/src/models/editor/document.ts @@ -105,8 +105,8 @@ export class Document protected activeLayerId?: string; protected measurementDisplayLayerId?: string; - protected layerMap: { [key: string]: ILayer }; - protected annotationGroupMap: { [key: string]: AnnotationGroup }; + protected layerMap: { [layerId: string]: ILayer }; + protected annotationGroupMap: { [annotionGroupId: string]: AnnotationGroup }; protected layerIds: string[]; public measurementType: MeasurementType = "volume"; @@ -190,7 +190,6 @@ export class Document title: computed, layers: computed, renderingOrder: computed, - flatRenderingOrder: computed, annotationGroups: computed, activeLayer: computed, measurementDisplayLayer: computed, @@ -201,6 +200,7 @@ export class Document maxVisibleLayers3d: computed, setTitle: action, + setLayerIds: action, setActiveLayer: action, setMeasurementDisplayLayer: action, setMeasurementType: action, @@ -239,7 +239,7 @@ export class Document } const { length } = this.layers; if (!length) return undefined; - const lastLayer = this.getLayer(this.layerIds[length - 1]); + const lastLayer = this.layers[this.layers.length - 1]; const layerMeta = lastLayer?.metadata; if (isMiaMetadata(layerMeta)) { return layerMeta?.dataUri?.split("/").pop(); @@ -260,6 +260,10 @@ export class Document return (this.renderer?.capabilities.maxTextures || 0) - generalTextures3d; } + public setLayerIds(layerIds: string[]) { + this.layerIds = layerIds; + } + public get layers(): ILayer[] { return this.layerIds.flatMap((id) => { if (this.annotationGroupMap[id]) { @@ -283,19 +287,6 @@ export class Document }); } - public get flatRenderingOrder(): (ILayer | IAnnotationGroup)[] { - return this.layerIds.flatMap((id) => { - const group = this.annotationGroupMap[id]; - if (group) { - const array: (ILayer | IAnnotationGroup)[] = [ - group as IAnnotationGroup, - ]; - return array.concat(group.layers); - } - return this.layerMap[id]; - }); - } - public get annotationGroups(): IAnnotationGroup[] { return this.layerIds .filter((id) => !!this.annotationGroupMap[id]) @@ -359,13 +350,6 @@ export class Document return id ? this.layerMap[id] : undefined; } - public getOrphanAnnotationLayers(): ILayer[] { - const orphanAnnotationLayers = this.layers.filter( - (l) => l.isAnnotation && !l.annotationGroup, - ); - return orphanAnnotationLayers ?? []; - } - public getAnnotationGroup(id: string): IAnnotationGroup | undefined { return this.annotationGroupMap[id]; } @@ -463,17 +447,41 @@ export class Document : defaultRegionGrowingPreviewColor; }; - public addNewAnnotationLayer = () => { + public addNewAnnotationGroup = () => { if (!this.mainImageLayer) return; const annotationColor = this.getFirstUnusedColor(); + const annotationLayer = ImageLayer.fromNewAnnotationForImage( + this.mainImageLayer.image, + this, + annotationColor, + ); + this.addLayer(annotationLayer); + + const annotationGroup = new AnnotationGroup( + { titleOverride: annotationLayer.title }, + this, + ); + this.addAnnotationGroup(annotationGroup); + annotationGroup.addLayer(annotationLayer); + + this.setActiveLayer(annotationLayer); + + // Force switch to 2D if too many layers for 3D + this.viewSettings.setViewMode(this.viewSettings.viewMode); + }; + + public addNewAnnotationLayer = () => { + if (!this.mainImageLayer) return; + const annotationColor = this.getFirstUnusedColor(); const annotationLayer = ImageLayer.fromNewAnnotationForImage( this.mainImageLayer.image, this, annotationColor, ); this.addLayer(annotationLayer); + this.activeLayer?.annotationGroup?.addLayer(annotationLayer); this.setActiveLayer(annotationLayer); // Force switch to 2D if too many layers for 3D @@ -487,8 +495,33 @@ export class Document this.layerMap[layerId].delete(); delete this.layerMap[layerId]; if (this.activeLayerId === layerId) { - this.setActiveLayer(this.layerIds[0]); + this.setActiveLayer(this.layers[0]); + } + }; + + public deleteAnnotationGroup = (annotationGroup: IAnnotationGroup): void => { + // Remove the annotationGroupId from layerIds + this.layerIds = this.layerIds.filter((id) => id !== annotationGroup.id); + + // Delete all layers from the annotation group + annotationGroup.layerIds.forEach((id) => this.deleteLayer(id)); + + if (this.activeLayerId === annotationGroup.id) { + this.setActiveLayer(this.layers[0]); } + + delete this.annotationGroupMap[annotationGroup.id]; + }; + + public removeLayerFromRootList = (layer: ILayer): void => { + this.setLayerIds(this.layerIds.filter((id) => id !== layer.id)); + }; + + public removeAnnotationGroup = ( + idOrGroup: string | IAnnotationGroup, + ): void => { + const groupId = typeof idOrGroup === "string" ? idOrGroup : idOrGroup.id; + this.setLayerIds(this.layerIds.filter((id) => id !== groupId)); }; /** Toggles the type of the layer (annotation or not) and repositions it accordingly */ @@ -496,9 +529,6 @@ export class Document const layerId = typeof idOrLayer === "string" ? idOrLayer : idOrLayer.id; const layer = this.getLayer(layerId); if (!layer) return; - if (layer.isAnnotation) { - layer.setAnnotationGroup(undefined); - } layer.setIsAnnotation(!layer.isAnnotation); }; @@ -531,21 +561,15 @@ export class Document }; // I/O - // eslint-disable-next-line @typescript-eslint/no-shadow - public exportZip = async (layers: ILayer[], limitToAnnotations?: boolean) => { - const zip = await this.zipLayers( - layers.filter((layer) => !limitToAnnotations || layer.isAnnotation), - ); + public exportZip = async (layersToZip: ILayer[], fileName?: string) => { + const zip = await this.zipLayers(layersToZip); if (this.context?.getTracker()?.isActive) { const trackingFile = this.context.getTracker()?.toFile(); if (trackingFile) zip.setFile(trackingFile.name, trackingFile); } - FileSaver.saveAs( - await zip.toBlob(), - `${this.title?.split(".")[0] ?? "annotation"}.zip`, - ); + FileSaver.saveAs(await zip.toBlob(), `${fileName ?? "annotation"}.zip`); }; public createZip = async ( @@ -564,25 +588,28 @@ export class Document public createSquashedNii = async ( // eslint-disable-next-line @typescript-eslint/no-shadow layers: ILayer[], - title?: string, + fileName?: string, ): Promise => { - const imageLayers = this.layers.filter( + const imageLayers = layers.filter( (potentialLayer) => potentialLayer instanceof ImageLayer && potentialLayer.isAnnotation, ) as ImageLayer[]; const file = await writeSingleMedicalImage( - imageLayers[imageLayers.length - 1].image.toITKImage( - imageLayers.slice(0, -1).map((layer) => layer.image), + imageLayers[0].image.toITKImage( + imageLayers.slice(1).map((layer) => layer.image), true, ), - `${title ?? this.title?.split(".")[0] ?? "annotaion"}.nii.gz`, + `${fileName ?? "annotaion"}.nii.gz`, ); return file; }; // eslint-disable-next-line @typescript-eslint/no-shadow - public exportSquashedNii = async (layers: ILayer[]) => { - const file: File | undefined = await this.createSquashedNii(layers); + public exportSquashedNii = async (layers: ILayer[], fileName?: string) => { + const file: File | undefined = await this.createSquashedNii( + layers, + fileName, + ); if (file) { const fileBlob = new Blob([file], { type: file.type }); FileSaver.saveAs( @@ -616,7 +643,7 @@ export class Document public finishBatchImport() { if (!this.layers.some((layer) => layer.isAnnotation)) { - this.addNewAnnotationLayer(); + this.addNewAnnotationGroup(); this.viewport2D.setMainViewType(); } this.context?.persist(); @@ -733,14 +760,26 @@ export class Document } else if (filteredFiles.name.endsWith(".zip")) { const zip = await Zip.fromZipFile(filteredFiles); const unzippedFiles = await zip.getAllFiles(); - await this.importFiles( - this.createAnnotationGroup( - unzippedFiles, + if ("annotationGroupId" in filteredFiles) { + const typedFilteredFiles = filteredFiles as FileWithAnnotationGroup; + const newUnzippedFiles = unzippedFiles.map((unzippedFile) => { + const newFile = unzippedFile as FileWithAnnotationGroup; + newFile.annotationGroupId = typedFilteredFiles.annotationGroupId; + newFile.metadata = typedFilteredFiles.metadata; + return newFile; + }); + await this.importFiles(newUnzippedFiles); + } else { + const fileName = path.basename(filteredFiles.name); + await this.importFiles( + this.createAnnotationGroup( + unzippedFiles, + fileName.slice(0, fileName.indexOf(".")), + this.getMetadataFromFile(filteredFiles), + ), filteredFiles.name, - this.getMetadataFromFile(filteredFiles), - ), - filteredFiles.name, - ); + ); + } return; } else if (filteredFiles.name.endsWith(".json")) { await readTrackingLog(filteredFiles, this); @@ -759,15 +798,6 @@ export class Document // return; // } - if (filteredFiles instanceof File) { - this.createAnnotationGroup( - [filteredFiles], - filteredFiles.name, - this.getMetadataFromFile(filteredFiles), - ); - } else { - this.createAnnotationGroup(filteredFiles, name ?? uuidv4()); - } let createdLayerId = ""; const isFirstLayer = !this.layerIds.length || !this.layers.some((l) => l.kind !== "group"); @@ -806,6 +836,18 @@ export class Document const imageWithUnit = { unit, ...image }; if (isAnnotation) { + // Creates an annotation group if it does not yet exists + if ( + !("annotationGroupId" in filteredFiles) && + filteredFiles instanceof File + ) { + const fileName = path.basename(filteredFiles.name); + this.createAnnotationGroup( + [filteredFiles], + fileName.slice(0, fileName.indexOf(".")), + this.getMetadataFromFile(filteredFiles), + ); + } createdLayerId = await this.importAnnotation(imageWithUnit); } else if (isAnnotation !== undefined) { createdLayerId = await this.importImage(imageWithUnit); @@ -867,10 +909,6 @@ export class Document name: `${layerIndex}_${imageWithUnit.name}`, ...prototypeImage, }); - if (files instanceof File) { - this.addLayerToAnnotationGroup(createdLayerId, files); - this.addMetadataToLayer(createdLayerId, files); - } } } else { this.setError({ @@ -899,6 +937,30 @@ export class Document // }); // } // } else { + + // Creates an annotation group if it does not yet exists + if ( + !("annotationGroupId" in filteredFiles) && + filteredFiles instanceof File + ) { + const fileName = path.basename(filteredFiles.name); + this.createAnnotationGroup( + [filteredFiles], + fileName.slice(0, fileName.indexOf(".")), + this.getMetadataFromFile(filteredFiles), + ); + } + if (uniqueValues.size === 1 && uniqueValues.has(0)) { + createdLayerId = await this.importAnnotation( + { ...imageWithUnit, name: `${image.name}` }, + undefined, + false, + ); + if (files instanceof File) { + this.addLayerToAnnotationGroup(createdLayerId, files); + this.addMetadataToLayer(createdLayerId, files); + } + } uniqueValues.forEach(async (value) => { if (value === 0) return; createdLayerId = await this.importAnnotation( @@ -924,20 +986,6 @@ export class Document this.history.clear(); } - if (files instanceof File) { - this.addLayerToAnnotationGroup(createdLayerId, files); - this.addMetadataToLayer(createdLayerId, files); - } - - // Move all annotation groups with only image layers to the end of the list: - this.annotationGroups.forEach((group) => { - if (!group.layers.every((layer) => !layer.isAnnotation)) return; - this.addAnnotationGroup( - group as AnnotationGroup, - this.annotationGroups.length - 1, - ); - }); - return createdLayerId; } @@ -1072,7 +1120,7 @@ export class Document const layer = this.getLayer(layerId); const group = this.getAnnotationGroupFromFile(file); if (layer && group) { - group?.addLayer(layer.id); + group.addLayer(layer); } } @@ -1117,7 +1165,7 @@ export class Document "Cannot create a new group for file that already belongs to a group", ); } - const annotationGroup = new AnnotationGroup({ title }, this); + const annotationGroup = new AnnotationGroup({ titleOverride: title }, this); annotationGroup.metadata = groupMetadata; const filesWithGroup = files.map((f) => { diff --git a/apps/editor/src/models/editor/layers/layer.ts b/apps/editor/src/models/editor/layers/layer.ts index 247e4e340..bb732c9d1 100644 --- a/apps/editor/src/models/editor/layers/layer.ts +++ b/apps/editor/src/models/editor/layers/layer.ts @@ -65,7 +65,6 @@ export class Layer implements ILayer, ISerializable { annotationGroup: computed, isActive: computed, - setAnnotationGroup: action, setIsAnnotation: action, setTitle: action, setBlendMode: action, @@ -110,24 +109,12 @@ export class Layer implements ILayer, ISerializable { public get annotationGroup(): IAnnotationGroup | undefined { return this.document.annotationGroups?.find((group) => - group.layers.includes(this), + group.layerIds.includes(this.id), ); } - public setAnnotationGroup(id?: string, index?: number): void { - if (!id) { - this.annotationGroup?.removeLayer(this.id, index); - this.document.addLayer(this, index); - return; - } - const newGroup = this.document.getAnnotationGroup(id); - newGroup?.addLayer(this.id, index); - } - public getAnnotationGroupLayers(): ILayer[] { - return ( - this.annotationGroup?.layers ?? this.document.getOrphanAnnotationLayers() - ); + return this.annotationGroup?.layers ?? []; } public setBlendMode = (value?: BlendMode): void => { @@ -198,7 +185,7 @@ export class Layer implements ILayer, ISerializable { } public delete() { - this.annotationGroup?.removeLayer?.(this.id); + this.annotationGroup?.removeLayer(this); if (this.document.layers.includes(this)) { this.document.deleteLayer(this.id); } diff --git a/apps/editor/src/models/review-strategy/review-strategy.ts b/apps/editor/src/models/review-strategy/review-strategy.ts index 671d9fd6e..aa73095d9 100644 --- a/apps/editor/src/models/review-strategy/review-strategy.ts +++ b/apps/editor/src/models/review-strategy/review-strategy.ts @@ -1,4 +1,5 @@ import { action, makeObservable, observable } from "mobx"; +import path from "path"; import { RootStore } from "../root"; import { ReviewStrategySnapshot } from "./review-strategy-snapshot"; @@ -67,16 +68,17 @@ export abstract class ReviewStrategy { ): Promise { if (!this.task?.annotationIds) return; await Promise.all( - this.task?.annotationIds.map(async (annotationId, idx) => { + this.task?.annotationIds.map(async (annotationId) => { const annotationFiles = await this.task?.getAnnotationFiles( annotationId, ); if (!annotationFiles) throw new Error("Annotation files not found"); + const fileName = path.basename(annotationFiles[0].name); const groupFiles = this.store.editor.activeDocument?.createAnnotationGroup( annotationFiles, - `Annotation ${idx + 1}`, + fileName.slice(0, fileName.indexOf(".")), getMetadataFromChild ? { ...annotationFiles[0]?.metadata } : { id: annotationId, kind: "annotation", backend: "who" }, diff --git a/libs/ui-shared/src/lib/types/editor/document.ts b/libs/ui-shared/src/lib/types/editor/document.ts index 98b182e92..71ac1477f 100644 --- a/libs/ui-shared/src/lib/types/editor/document.ts +++ b/libs/ui-shared/src/lib/types/editor/document.ts @@ -35,12 +35,6 @@ export interface IDocument { * top-to-bottom */ renderingOrder: (ILayer | IAnnotationGroup)[]; - /** - * The document's layer and annotation group stack. - * This contains all layers and all annotation groups, sorted by their renderingOrder - * annotation groups are followed by the layers within that group top-to-bottom - */ - flatRenderingOrder: (ILayer | IAnnotationGroup)[]; /** `true` if the document holds three-dimensional layers. */ has3DLayers: boolean; @@ -104,9 +98,6 @@ export interface IDocument { getAnnotationGroup(id: string): IAnnotationGroup | undefined; - /* returns all annotation layers that do not have an annotation group */ - getOrphanAnnotationLayers(): ILayer[]; - /** Sets the active layer. */ setActiveLayer(idOrLayer?: string | ILayer): void; @@ -124,6 +115,9 @@ export interface IDocument { /** Deletes a layer from the document. */ deleteLayer(idOrLayer: string | ILayer): void; + /** Remove a layer only from the root layer id list (e.g. to add it to a group next). */ + removeLayerFromRootList(layer: ILayer): void; + /** Returns the first color that is not yet used to color any layer. */ getFirstUnusedColor(): string; /** Returns the color to be used for, e.g., 3D region growing preview. */ diff --git a/libs/ui-shared/src/lib/types/editor/layers.ts b/libs/ui-shared/src/lib/types/editor/layers.ts index 6d9c7a9cf..8193c6171 100644 --- a/libs/ui-shared/src/lib/types/editor/layers.ts +++ b/libs/ui-shared/src/lib/types/editor/layers.ts @@ -48,7 +48,8 @@ export interface LayerSnapshot { export interface AnnotationGroupSnapshot { id: string; - title: string; + + titleOverride?: string; metadata?: BackendMetadata; layerIds: string[]; } @@ -120,12 +121,6 @@ export interface ILayer { setMetadata(value?: BackendMetadata): void; - /** Sets the layer's annotation group and moves it to the specified index within its local rendering order. - * A layer with an undefined annotation group is an orphan. - * If the layer is an orphan its local rendering order is the document renderingOrder. - */ - setAnnotationGroup(id: string | undefined, idx?: number): void; - getAnnotationGroupLayers(): ILayer[]; setIsAnnotation(value?: boolean): void; @@ -216,9 +211,11 @@ export interface IImageLayer extends ILayer { export interface IAnnotationGroup { id: string; /** The group's display name. */ - title: string; + title?: string; /** The group's meta data. Usually the object from the DB */ metadata?: BackendMetadata; + /** All layer ids in the group. */ + layerIds: string[]; /** All layers in the group. */ layers: ILayer[]; /** Whether the group is currently collapsed in the layer view* */ @@ -227,16 +224,24 @@ export interface IAnnotationGroup { isActive: boolean; /** Returns `true` if the annotation group has changes. */ hasChanges: boolean; - /** Adds a layer with specified id to the group at the specified index, the layer is removed from its previous group. - * If no index and the layer is already in the group, the layer's position remains unchanged. - * If no index is specified and the layer is not part of the group, the layer is inserted at the beginning. */ - addLayer(id: string, index?: number): void; - /** Removes a layer from the group making it an orphan. - * After being removed, the layer is added to the document at the specified index. - */ - removeLayer(id: string, index?: number): void; + /** Sets the group's title. */ + setTitle(value?: string): void; + /** Sets the flag if the group experiences a change in the number of layers. */ + setHasUnsavedChanges(value: boolean): void; + /** Adds a layer to the group. Also removes it from the document root. */ + addLayer(layer: ILayer): void; + /** Removes the layer from the group, if it is part of the group. */ + removeLayer(layer: ILayer): void; /** set verified if fam */ trySetIsVerified(value: boolean): void; + /** Sets the layer ids of the group, e.g. for sorting. */ + setLayerIds(ids: string[]): void; + /** Sets collapsed state of the group. */ + setCollapsed(value: boolean): void; + + /** Delete this annotation group from the document and all + * the layers it contains. */ + delete(): void; toJSON(): AnnotationGroupSnapshot; } diff --git a/package.json b/package.json index cc15c5d42..1f956d27b 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,6 @@ "axios": "^1.2.2", "core-js": "^3.6.5", "css-element-queries": "^1.2.3", - "dnd-kit-sortable-tree": "^0.1.73", "file-saver": "^2.0.5", "hotkeys-js": "^3.10.1", "i18next": "^19.8.4", diff --git a/yarn.lock b/yarn.lock index becede606..e4916a7d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10577,13 +10577,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.2.1": - version: 1.2.1 - resolution: "clsx@npm:1.2.1" - checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 - languageName: node - linkType: hard - "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -11884,22 +11877,6 @@ __metadata: languageName: node linkType: hard -"dnd-kit-sortable-tree@npm:^0.1.73": - version: 0.1.73 - resolution: "dnd-kit-sortable-tree@npm:0.1.73" - dependencies: - clsx: ^1.2.1 - react-merge-refs: ^2.0.1 - peerDependencies: - "@dnd-kit/core": ">=6.0.5" - "@dnd-kit/sortable": ">=7.0.1" - "@dnd-kit/utilities": ">=3.2.0" - react: ">=16" - react-dom: ">=16" - checksum: 86bec921ebb4484f03848fccac21654b9a98ef590978815c45b908a297d28faf88094093545e74387315a70a8f661c497d32987f34573e6cd2bd44aed0314cad - languageName: node - linkType: hard - "dns-equal@npm:^1.0.0": version: 1.0.0 resolution: "dns-equal@npm:1.0.0" @@ -20511,13 +20488,6 @@ __metadata: languageName: node linkType: hard -"react-merge-refs@npm:^2.0.1": - version: 2.0.2 - resolution: "react-merge-refs@npm:2.0.2" - checksum: 64758870d79ad52e6666d1d30cdecd5a72722edfd5c89808b41acdbd81a039f0c78b8b576f7ae247010468fc45cb57dd31f402693c64224439dbe0127f4389f3 - languageName: node - linkType: hard - "react-native-get-random-values@npm:^1.4.0": version: 1.8.0 resolution: "react-native-get-random-values@npm:1.8.0" @@ -24132,7 +24102,6 @@ __metadata: core-js: ^3.6.5 css-element-queries: ^1.2.3 cypress: ^10.7.0 - dnd-kit-sortable-tree: ^0.1.73 eslint: ~8.15.0 eslint-config-airbnb: ^19.0.4 eslint-config-prettier: 8.1.0