diff --git a/apps/editor/src/assets/de.json b/apps/editor/src/assets/de.json index 280a835ed..47643005a 100644 --- a/apps/editor/src/assets/de.json +++ b/apps/editor/src/assets/de.json @@ -94,6 +94,7 @@ "add-annotation-layer": "Neue Annotation hinzufügen", "no-layers": "Keine Ebenen geladen.", "untitled-layer": "Unbenannte Ebene", + "untitled-layer-group": "Unbenannte Ebenengruppe", "layer-settings": "Ebeneneinstellungen", "opacity": "Deckkraft", "background-color": "Hintergrundfarbe", @@ -104,6 +105,8 @@ "rename-layer": "Ebene umbenennen", "delete-layer": "Ebene löschen", "delete-layer-confirmation": "Ebene \"{{layer}}\" löschen?", + "delete-layer-group": "Ebenengruppe löschen", + "delete-layer-group-confirmation": "Ebenengruppe \"{{layerGroup}}\" löschen?", "view-settings": "Ansichtseinstellungen", "transverse": "Transverse (1)", diff --git a/apps/editor/src/assets/en.json b/apps/editor/src/assets/en.json index 124fbf133..0e228260f 100644 --- a/apps/editor/src/assets/en.json +++ b/apps/editor/src/assets/en.json @@ -94,6 +94,7 @@ "add-annotation-layer": "Add new annotation", "no-layers": "No layers loaded.", "untitled-layer": "Untitled layer", + "untitled-layer-group": "Untitled layer group", "layer-settings": "Layer Settings", "opacity": "Opacity", "background-color": "Background Color", @@ -104,6 +105,8 @@ "rename-layer": "Rename Layer", "delete-layer": "Delete Layer", "delete-layer-confirmation": "Delete layer \"{{layer}}\"?", + "delete-layer-group": "Delete Layer Group", + "delete-layer-group-confirmation": "Delete layer group \"{{layerGroup}}\"?", "view-settings": "View Settings", "transverse": "Transverse (1)", diff --git a/apps/editor/src/components/editor/layers/layers.tsx b/apps/editor/src/components/editor/layers/layers.tsx index 12b5fc4c4..2ff9e7e3f 100644 --- a/apps/editor/src/components/editor/layers/layers.tsx +++ b/apps/editor/src/components/editor/layers/layers.tsx @@ -5,6 +5,7 @@ import { FloatingUIButton, IImageLayer, ILayer, + ILayerGroup, InfoText, List, ListItem, @@ -75,7 +76,8 @@ const LayerListItem = observer<{ index: number; isActive?: boolean; isLast?: boolean; -}>(({ layer, index, isActive, isLast }) => { + isChild?: boolean; +}>(({ layer, index, isActive, isLast, isChild }) => { const store = useStore(); const toggleAnnotationVisibility = useCallback(() => { @@ -251,6 +253,7 @@ const LayerListItem = observer<{ onTrailingIconPress={toggleAnnotationVisibility} isActive={isActive} isLast={isLast || snapshot.isDragging} + isChild={isChild} onPointerDown={handlePointerDown} onPointerUp={stopTap} onContextMenu={handleContextMenu} @@ -325,6 +328,229 @@ const LayerListItem = observer<{ ); }); +const LayerGroupListItem = observer<{ + layerGroup: ILayerGroup; + index: number; + isActive?: boolean; + isLast?: boolean; +}>(({ layerGroup, index, isActive, isLast }) => { + const store = useStore(); + + // TODO: Automatically adjust group visibility (icon) if all children (in)visible + const toggleAnnotationVisibility = useCallback(() => { + layerGroup.layers.forEach((layer) => { + layer.setIsVisible(!layerGroup.isVisible); + }); + layerGroup.setIsVisible(!layerGroup.isVisible); + }, [layerGroup]); + + // Color Modal Toggling + // const [areLayerSettingsOpen, setAreLayerSettingsOpen] = useState(false); + // const isOpeningRef = useRef(false); + // const resetOpeningRef = useCallback(() => { + // isOpeningRef.current = false; + // }, []); + // const [schedule, cancel] = useDelay(resetOpeningRef, 25); + // const openLayerSettings = useCallback(() => { + // setAreLayerSettingsOpen(true); + // isOpeningRef.current = true; + // schedule(); + // }, [schedule]); + // const closeLayerSettings = useCallback(() => { + // if (!isOpeningRef.current) setAreLayerSettingsOpen(false); + // isOpeningRef.current = false; + // cancel(); + // }, [cancel]); + + // Color Modal Positioning + const [colorRef, setColorRef] = useState< + HTMLDivElement | SVGSVGElement | null + >(null); + + const trailingIconRef = useRef(null); + + const [isGroupOpen, setIsGroupOpen] = useState(true); + + const [startTap1, stopTap] = useShortTap( + useCallback(() => { + // setIsGroupOpen(!isGroupOpen); + }, []), + ); + const startTap2 = useDoubleTap( + useCallback((event: React.PointerEvent) => { + if (event.pointerType === "mouse") return; + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + }, []), + ); + const startTap = useForwardEvent(startTap1, startTap2); + + // Context Menu + const [contextMenuPosition, setContextMenuPosition] = useState( + null, + ); + const closeContextMenu = useCallback(() => { + setContextMenuPosition(null); + }, []); + useEffect(() => { + setContextMenuPosition(null); + }, [store?.editor.activeDocument?.viewSettings.viewMode]); + + const { t } = useTranslation(); + + // const exportLayer = useCallback(() => { + // if (layer.kind !== "image") return; + // (layer as ImageLayer).quickExport().then(() => { + // setContextMenuPosition(null); + // }); + // }, [layer]); + + const deleteLayerGroup = useCallback(() => { + if ( + // eslint-disable-next-line no-alert + window.confirm( + t("delete-layer-group-confirmation", { layerGroup: layerGroup.title }), + ) + ) { + layerGroup.delete(); + } + setContextMenuPosition(null); + }, [layerGroup, t]); + + // Press Handler + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if ( + colorRef?.contains(event.target as Node) || + trailingIconRef.current?.contains(event.target as Node) + ) { + return; + } + + if (event.button === PointerButton.LMB) { + startTap(event); + } else if (event.button === PointerButton.RMB) { + setContextMenuPosition({ x: event.clientX, y: event.clientY }); + } + }, + [colorRef, startTap], + ); + + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + // Layer Renaming Handling + const [isLayerGroupNameEditable, setIsLayerGroupNameEditable] = useState( + false, + ); + const startEditingLayerGroupName = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + setIsLayerGroupNameEditable(true); + closeContextMenu(); + }, + [closeContextMenu], + ); + const stopEditingLayerGroupName = useCallback(() => { + setIsLayerGroupNameEditable(false); + }, []); + + const layers = store?.editor.activeDocument?.layers; + const layerCount = layers?.length; + const activeLayer = store?.editor.activeDocument?.activeLayer; + const activeLayerIndex = layers?.findIndex((layer) => layer === activeLayer); + + const modalRootRef = useModalRoot(); + return ( + <> + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { + const node = ( + + {() => ( + { + setIsGroupOpen(!isGroupOpen); + }} + labelTx={ + layerGroup.title ? undefined : "untitled-layer-group" + } + label={layerGroup.title} + isLabelEditable={isLayerGroupNameEditable} + onChangeLabelText={layerGroup.setTitle} + onConfirmLabelText={stopEditingLayerGroupName} + trailingIcon={layerGroup.isVisible ? "eye" : "eyeCrossed"} + disableTrailingIcon={!layerGroup.isVisible} + trailingIconRef={trailingIconRef} + onTrailingIconPress={toggleAnnotationVisibility} + isActive={isActive} + isLast={isLast || snapshot.isDragging} + onPointerDown={handlePointerDown} + onPointerUp={stopTap} + onContextMenu={handleContextMenu} + /> + )} + + ); + + return snapshot.isDragging && modalRootRef.current + ? ReactDOM.createPortal(node, modalRootRef.current) + : node; + }} + + {isGroupOpen && + layerGroup.layers.map((layer, layerIndex) => ( + + ))} + {/* */} + + {/* */} + + + + + ); +}); + const LayerModal = styled(Modal)` padding-bottom: 0px; width: 230px; @@ -409,18 +635,35 @@ export const Layers: React.FC = observer(() => { > {layerCount ? ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - layers!.map((layer, index) => ( - - )) + layers!.map((layer, index) => + layer.kind === "group" ? ( + + ) : ( + <> + {layer.parent ? undefined : ( + + )} + + ), + ) ) : ( diff --git a/apps/editor/src/models/editor/document.ts b/apps/editor/src/models/editor/document.ts index 4cec3c9e5..ab038bf3f 100644 --- a/apps/editor/src/models/editor/document.ts +++ b/apps/editor/src/models/editor/document.ts @@ -12,6 +12,7 @@ import { TrackingLog, ErrorNotification, ValueType, + ILayerGroup, PerformanceMode, } from "@visian/ui-shared"; import { @@ -182,6 +183,7 @@ export class Document setActiveLayer: action, setMeasurementDisplayLayer: action, setMeasurementType: action, + reorderLayersForGrouping: action, addLayer: action, addNewAnnotationLayer: action, moveLayer: action, @@ -313,10 +315,28 @@ export class Document this.measurementType = measurementType; }; + public reorderLayersForGrouping = (idOrLayer: string | ILayer): void => { + const layer = + typeof idOrLayer === "string" ? this.getLayer(idOrLayer) : idOrLayer; + if (!layer) return; + if (layer.kind === "group") { + (layer as ILayerGroup).layers.forEach((childLayer, index) => { + this.moveLayer(childLayer, this.layerIds.indexOf(layer.id) + index + 1); + }); + } + if (layer.parent) { + // TODO: Move to beginning or end of group based on image/annotation + this.moveLayer(layer, this.layerIds.indexOf(layer.parent.id + 1)); + } + }; + public addLayer = (...newLayers: Layer[]): void => { newLayers.forEach((layer) => { this.layerMap[layer.id] = layer; - if (layer.isAnnotation) { + if (layer.kind === "group") { + this.layerIds.unshift(layer.id); + this.reorderLayersForGrouping(layer); + } else if (layer.isAnnotation) { this.layerIds.unshift(layer.id); } else { // insert image layer after all annotation layers @@ -329,6 +349,7 @@ export class Document } this.layerIds.splice(insertIndex, 0, layer.id); } + if (layer.parent) this.reorderLayersForGrouping(layer); }); }; diff --git a/apps/editor/src/models/editor/layers/layer-group.ts b/apps/editor/src/models/editor/layers/layer-group.ts index 3dcae272f..b1fb7082f 100644 --- a/apps/editor/src/models/editor/layers/layer-group.ts +++ b/apps/editor/src/models/editor/layers/layer-group.ts @@ -23,6 +23,9 @@ export class LayerGroup ) { super(snapshot, document); + // TODO: Investigate why it has to be set here again + this.layerIds = snapshot?.layerIds || []; + makeObservable(this, { layerIds: observable, @@ -46,6 +49,8 @@ export class LayerGroup this.layerIds.push(idOrLayer.id); idOrLayer.setParent(this.id); + + this.document.reorderLayersForGrouping(idOrLayer); } public removeLayer(idOrLayer: string | ILayer) { @@ -60,6 +65,13 @@ export class LayerGroup idOrLayer.setParent(); } + public delete(): void { + this.layers.forEach((layer) => { + layer.delete(); + }); + super.delete(); + } + // Serialization public toJSON(): LayerGroupSnapshot { return { diff --git a/libs/ui-shared/src/lib/components/list/list.props.ts b/libs/ui-shared/src/lib/components/list/list.props.ts index dd855f2ab..53e7a8c6c 100644 --- a/libs/ui-shared/src/lib/components/list/list.props.ts +++ b/libs/ui-shared/src/lib/components/list/list.props.ts @@ -20,6 +20,8 @@ export interface ListItemProps extends React.HTMLAttributes { */ isLast?: boolean; + isChild?: boolean; + isLabelEditable?: boolean; onChangeLabelText?: (string: string) => void; onConfirmLabelText?: (value: string) => void; diff --git a/libs/ui-shared/src/lib/components/list/list.tsx b/libs/ui-shared/src/lib/components/list/list.tsx index f54c08aed..8cd0fe17b 100644 --- a/libs/ui-shared/src/lib/components/list/list.tsx +++ b/libs/ui-shared/src/lib/components/list/list.tsx @@ -17,10 +17,16 @@ export const List = styled.div` width: 100%; `; -const ListItemContainer = styled.div` +const ListItemContainer = styled.div>` display: flex; flex-direction: column; outline: none; + + ${(props) => + props.isChild && + css` + margin-left: 10px; + `} `; const ListItemInner = styled.div>` @@ -104,6 +110,7 @@ export const ListItem = React.forwardRef( value, isActive, isLast, + isChild, isLabelEditable = false, onChangeLabelText, onConfirmLabelText, @@ -142,7 +149,7 @@ export const ListItem = React.forwardRef( }); return ( - + {icon && (typeof icon === "string" ? ( diff --git a/libs/ui-shared/src/lib/types/editor/document.ts b/libs/ui-shared/src/lib/types/editor/document.ts index 8641392e5..658143493 100644 --- a/libs/ui-shared/src/lib/types/editor/document.ts +++ b/libs/ui-shared/src/lib/types/editor/document.ts @@ -95,6 +95,8 @@ export interface IDocument { /** Sets the type of measurement that should be displayed. */ setMeasurementType(measurementType: MeasurementType): void; + reorderLayersForGrouping(idOrLayer: string | ILayer): void; + /** Adds a layer to the document. */ addLayer(layer: ILayer): void;