- {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
- const node = (
-
- {() => (
-
- {layer === store?.editor.activeDocument?.mainImageLayer && (
-
- )}
-
- )}
-
- );
-
- return snapshot.isDragging && modalRootRef.current
- ? ReactDOM.createPortal(node, modalRootRef.current)
- : node;
+ const family = store?.editor.activeDocument?.getLayerFamily(id);
+ if (family) {
+ const isActive = !!family.collapsed && family.isActive;
+ return (
+
+
+
+ );
+ }
+ const layer = store?.editor.activeDocument?.getLayer(id);
+ if (layer) {
+ return (
+
-
-
- {layer.kind === "image" && layer.isAnnotation && (
- <>
- {layer.is3DLayer && (
-
- )}
-
- >
- )}
-
-
- {store?.editor.activeDocument?.viewSettings.viewMode === "2D" && (
-
- )}
-
-
-
- >
+
+ );
+ }
+ return (
+
+
+
);
-});
+};
-const LayerModal = styled(Modal)`
- padding-bottom: 0px;
- width: 230px;
- justify-content: center;
-`;
+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();
@@ -341,17 +195,6 @@ export const Layers: React.FC = observer(() => {
// Menu Positioning
const [buttonRef, setButtonRef] = useState(null);
- const handleDrag = useCallback(
- (result: DragUpdate) => {
- if (!result.destination) return;
- store?.editor.activeDocument?.moveLayer(
- result.draggableId,
- result.destination.index,
- );
- },
- [store],
- );
-
// 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;
@@ -362,10 +205,136 @@ 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 = store?.editor.activeDocument?.layers;
- const layerCount = layers?.length;
- const activeLayer = store?.editor.activeDocument?.activeLayer;
- const activeLayerIndex = layers?.findIndex((layer) => layer === activeLayer);
+ const canFamilyHaveChildren = useCallback(
+ (family: ILayerFamily) => {
+ const callback = (draggedItem: TreeItem) => {
+ const layer = store?.editor.activeDocument?.getLayer(draggedItem.value);
+ if (store?.reviewStrategy && layer && !family.layers.includes(layer)) {
+ return false;
+ }
+
+ return !!layer;
+ };
+ return callback;
+ },
+ [store?.editor.activeDocument, store?.reviewStrategy],
+ );
+
+ const getTreeItems = useCallback(() => {
+ const layerFamilyToTreeItemData = (layerFamily: ILayerFamily) => ({
+ id: layerFamily.id,
+ value: layerFamily.id,
+ children: layerFamily.layers.map((layer) => layerToTreeItemData(layer)),
+ collapsed: layerFamily.collapsed,
+ canHaveChildren: canFamilyHaveChildren(layerFamily),
+ disableSorting: false,
+ });
+
+ const renderingOrder = store?.editor.activeDocument?.renderingOrder;
+ if (!renderingOrder) {
+ return [];
+ }
+
+ return renderingOrder.map((element) => {
+ if (element instanceof LayerFamily) {
+ const family = element as LayerFamily;
+ return layerFamilyToTreeItemData(family);
+ }
+ if (element instanceof ImageLayer) {
+ const layer = element as ImageLayer;
+ return layerToTreeItemData(layer);
+ }
+ return { id: "undefined", value: "undefined" };
+ });
+ }, [canFamilyHaveChildren, store?.editor.activeDocument?.renderingOrder]);
+
+ const treeItems = getTreeItems();
+
+ 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.family === undefined;
+ },
+ [store?.editor.activeDocument],
+ );
+ const updateRenderingOrder = useCallback(
+ (
+ newTreeItems: TreeItems,
+ change: ItemChangedReason,
+ ) => {
+ if (change.type === "removed") return;
+ if (change.type === "collapsed" || change.type === "expanded") {
+ const family = store?.editor.activeDocument?.getLayerFamily(
+ change.item.value,
+ );
+ if (!family) return;
+ family.collapsed = change.item.collapsed;
+ return;
+ }
+ if (change.type === "dropped") {
+ const draggedLayer = store?.editor.activeDocument?.getLayer(
+ change.draggedItem.value,
+ );
+ if (
+ store?.reviewStrategy &&
+ draggedLayer &&
+ (change.draggedFromParent
+ ? change.draggedFromParent.value
+ : undefined) !==
+ (change.droppedToParent ? change.droppedToParent.value : undefined)
+ ) {
+ return;
+ }
+ newTreeItems.forEach((item, index) => {
+ const layer = store?.editor.activeDocument?.getLayer(item.value);
+ if (layer) {
+ layer?.setFamily(undefined, index);
+ }
+ const family = store?.editor.activeDocument?.getLayerFamily(
+ item.value,
+ );
+ if (family) {
+ item.children?.forEach((childItem, childIndex) => {
+ const childLayer = store?.editor.activeDocument?.getLayer(
+ childItem.value,
+ );
+ if (childLayer) {
+ childLayer.setFamily(family.id, childIndex);
+ }
+ });
+ family.collapsed = item.collapsed;
+ store?.editor.activeDocument?.addLayerFamily(
+ family as LayerFamily,
+ index,
+ );
+ }
+ });
+ }
+ },
+ [store?.editor.activeDocument, store?.reviewStrategy],
+ );
+
+ const firstElement = store?.editor.activeDocument?.renderingOrder[0];
+ const isHeaderDivideVisible = !(
+ firstElement?.isActive &&
+ (firstElement instanceof LayerFamily ? firstElement.collapsed : true)
+ );
+
+ if (!layers) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
{
/>
{
icon="plus"
tooltipTx="add-annotation-layer"
isDisabled={
- !layerCount ||
- layerCount >= (store?.editor.activeDocument?.maxLayers || 0)
+ !store?.editor.activeDocument?.imageLayers?.length ||
+ store?.editor.activeDocument?.imageLayers?.length >=
+ (store?.editor.activeDocument?.maxVisibleLayers || 0)
}
onPointerDown={
store?.editor.activeDocument?.addNewAnnotationLayer
@@ -404,39 +374,28 @@ export const Layers: React.FC = observer(() => {
>
}
>
- {/* TODO: Should we update on every drag change or just on drop? */}
-
-
- {(provided: DroppableProvided) => (
-
- {layerCount ? (
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- layers!.map((layer, index) => (
-
- ))
- ) : (
-
-
-
- )}
- {provided.placeholder}
-
+
+
+ false }}
+ indentationWidth={20}
+ />
+ {layers.length === 0 ? (
+
+
+
+ ) : (
+ false
)}
-
-
+
+
>
);
diff --git a/apps/editor/src/components/editor/menu/menu.tsx b/apps/editor/src/components/editor/menu/menu.tsx
index 0613ab589..03a1cf712 100644
--- a/apps/editor/src/components/editor/menu/menu.tsx
+++ b/apps/editor/src/components/editor/menu/menu.tsx
@@ -1,12 +1,11 @@
import {
- BlueButtonParam,
ButtonParam,
+ ColoredButtonParam,
ColorMode,
Divider,
EnumParam,
FloatingUIButton,
Modal,
- RedButtonParam,
Theme,
} from "@visian/ui-shared";
import { observer } from "mobx-react-lite";
@@ -45,10 +44,8 @@ export const Menu: React.FC = observer(
const [buttonRef, setButtonRef] = useState(null);
// Menu Actions
- const setTheme = useCallback(
- (value: string) => {
- store?.setColorMode(value as ColorMode);
- },
+ const setColorMode = useCallback(
+ (value: string) => store?.settings.setColorMode(value as ColorMode),
[store],
);
@@ -78,7 +75,7 @@ export const Menu: React.FC = observer(
return (
<>
= observer(
labelTx="theme"
options={themeSwitchOptions}
value={store?.colorMode || "dark"}
- setValue={setTheme}
+ setValue={setColorMode}
/>
{feedbackMailAddress && (
-
)}
-
+
>
);
diff --git a/apps/editor/src/components/editor/review-bar/index.ts b/apps/editor/src/components/editor/review-bar/index.ts
new file mode 100644
index 000000000..2c6a0e4ca
--- /dev/null
+++ b/apps/editor/src/components/editor/review-bar/index.ts
@@ -0,0 +1 @@
+export * from "./review-bar";
diff --git a/apps/editor/src/components/editor/review-bar/review-bar.tsx b/apps/editor/src/components/editor/review-bar/review-bar.tsx
new file mode 100644
index 000000000..a2e8576e7
--- /dev/null
+++ b/apps/editor/src/components/editor/review-bar/review-bar.tsx
@@ -0,0 +1,320 @@
+import {
+ color,
+ ColoredButtonParam,
+ fontSize,
+ InvisibleButton,
+ Sheet,
+ sheetNoise,
+ SquareButton,
+ Text,
+ useTranslation,
+ zIndex,
+} from "@visian/ui-shared";
+import { MiaAnnotationMetadata } from "@visian/utils";
+import { observer } from "mobx-react-lite";
+import { useCallback, useMemo } from "react";
+import styled from "styled-components";
+
+import { useStore } from "../../../app/root-store";
+import { whoHome } from "../../../constants";
+import { MiaReviewTask } from "../../../models/review-strategy";
+
+const ReviewBarSheet = styled(Sheet)`
+ width: 800px;
+ height: 70px;
+ padding: 10px 28px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: ${zIndex("modal")};
+`;
+
+const TaskContainer = styled.div`
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+`;
+
+const TaskLabel = styled(Text)`
+ font-size: ${fontSize("small")};
+ line-height: 10px;
+ height: 12px;
+ padding-bottom: 0px;
+ color: ${color("lightText")};
+`;
+
+const TaskName = styled(Text)`
+ font-size: 18px;
+ line-height: 18px;
+ margin-right: 20px;
+ padding-top: 2px;
+ white-space: wrap;
+`;
+
+const ActionContainer = styled.div`
+ height: 100%;
+ min-width: 450px;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ border-radius: 10px;
+ border: 1px solid ${color("sheetBorder")};
+ padding: 20px;
+ box-sizing: border-box;
+`;
+
+const ActionName = styled(Text)`
+ font-size: 18px;
+ line-height: 18px;
+ margin-right: 10px;
+ padding-top: 2px;
+ white-space: wrap;
+`;
+
+const ActionButtonsContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+`;
+
+const ActionButtons = styled(SquareButton)`
+ margin: 0px 5px;
+ width: 40px;
+`;
+
+const ReviewContainer = styled.div`
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+`;
+
+const ReviewToolsContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ position: relative;
+`;
+
+const ColoredButton = styled(ColoredButtonParam)`
+ width: 40px;
+ padding: 0;
+ margin: 0px 4px;
+`;
+
+const SkipButton = styled(InvisibleButton)`
+ width: 40px;
+ height: 40px;
+ margin: 0px;
+`;
+
+const ReviewMessageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: auto;
+ position: absolute;
+ top: -150px;
+ left: 50%;
+ transform: translateX(-50%);
+`;
+
+const ReviewMessageConnector = styled.div`
+ width: 1px;
+ height: 60px;
+ margin: 10px 0px;
+ background-color: ${color("lightText")};
+`;
+
+const ReviewMessage = styled(Sheet)`
+ background: ${sheetNoise}, ${color("blueSheet")};
+ border-color: ${color("blueBorder")};
+ width: 300px;
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ padding: 14px 20px;
+ box-sizing: border-box;
+`;
+
+const ReviewMessageTitle = styled(Text)`
+ font-size: 18px;
+ line-height: 18px;
+ height: 18px;
+ padding-bottom: 8px;
+`;
+
+const ReviewMessageSubtitle = styled(Text)`
+ font-size: 16px;
+ line-height: 12px;
+ height: 12px;
+ color: ${color("lightText")};
+`;
+
+export const WhoReviewBar = observer(() => {
+ const store = useStore();
+
+ const confirmTaskAnnotation = useCallback(async () => {
+ store?.reviewStrategy?.nextTask();
+ }, [store?.reviewStrategy]);
+
+ const skipTaskAnnotation = useCallback(async () => {
+ store?.reviewStrategy?.nextTask();
+ }, [store?.reviewStrategy]);
+
+ return store?.editor.activeDocument ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {false && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ ) : null;
+});
+
+export const MiaReviewBar = observer(
+ ({ openSavePopup }: { openSavePopup: () => void }) => {
+ const store = useStore();
+ const { t } = useTranslation();
+
+ const nextTask = useCallback(async () => {
+ store?.reviewStrategy?.nextTask();
+ }, [store?.reviewStrategy]);
+
+ const isVerified = useMemo(
+ () =>
+ (
+ store?.editor.activeDocument?.activeLayer?.family
+ ?.metadata as MiaAnnotationMetadata
+ )?.verified ?? false,
+ [store?.editor.activeDocument?.activeLayer?.family?.metadata],
+ );
+
+ const toggleVerification = useCallback(() => {
+ store?.editor.activeDocument?.activeLayer?.family?.trySetIsVerified(
+ !isVerified,
+ );
+ }, [store?.editor.activeDocument?.activeLayer?.family, isVerified]);
+
+ return store?.editor.activeDocument ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null;
+ },
+);
diff --git a/apps/editor/src/components/editor/save-popup/index.ts b/apps/editor/src/components/editor/save-popup/index.ts
new file mode 100644
index 000000000..d579ee08c
--- /dev/null
+++ b/apps/editor/src/components/editor/save-popup/index.ts
@@ -0,0 +1,2 @@
+export * from "./save-popup";
+export * from "./save-popup.props";
diff --git a/apps/editor/src/components/editor/save-popup/save-popup.props.ts b/apps/editor/src/components/editor/save-popup/save-popup.props.ts
new file mode 100644
index 000000000..d1a400795
--- /dev/null
+++ b/apps/editor/src/components/editor/save-popup/save-popup.props.ts
@@ -0,0 +1,3 @@
+import type { StatefulPopUpProps } from "@visian/ui-shared";
+
+export type SavePopUpProps = StatefulPopUpProps;
diff --git a/apps/editor/src/components/editor/save-popup/save-popup.tsx b/apps/editor/src/components/editor/save-popup/save-popup.tsx
new file mode 100644
index 000000000..3fc77d069
--- /dev/null
+++ b/apps/editor/src/components/editor/save-popup/save-popup.tsx
@@ -0,0 +1,399 @@
+import {
+ Button,
+ DropDown,
+ ILayer,
+ LayerList,
+ PopUp,
+ Text,
+ TextField,
+ useTranslation,
+} from "@visian/ui-shared";
+import {
+ BackendMetadata,
+ FileWithMetadata,
+ isMiaImageMetadata,
+ MiaAnnotation,
+ MiaMetadata,
+} from "@visian/utils";
+import { AxiosError } from "axios";
+import { observer } from "mobx-react-lite";
+import path from "path";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { useStore } from "../../../app/root-store";
+import { importFilesToDocument } from "../../../import-handling";
+import { LayerFamily } from "../../../models/editor/layer-families";
+import { MiaReviewTask } from "../../../models/review-strategy";
+import { fetchAnnotationFile } from "../../../queries";
+import { SavePopUpProps } from "./save-popup.props";
+
+const SectionLabel = styled(Text)`
+ font-size: 14px;
+ margin-bottom: 8px;
+`;
+
+const SaveAsInput = styled(TextField)`
+ margin: 0px 10px 0px 0px;
+ width: 100%;
+`;
+
+const SaveInput = styled(TextField)`
+ margin: 0px 10px 0px 0px;
+ width: 100%;
+ color: gray;
+`;
+
+const SaveButton = styled(Button)`
+ min-width: 110px;
+`;
+
+const InlineRow = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ margin-bottom: 28px;
+`;
+
+const InlineRowLast = styled(InlineRow)`
+ margin-bottom: 10px;
+`;
+
+const SavePopUpContainer = styled(PopUp)`
+ align-items: left;
+ width: 60%;
+`;
+
+const StyledDropDown = styled(DropDown)`
+ margin: 0px 10px 0px 0px;
+ width: 200px;
+`;
+
+export const SavePopUp = observer(({ isOpen, onClose }) => {
+ const store = useStore();
+ const [newAnnotationURIPrefix, setnewAnnotationURIPrefix] = useState("");
+
+ const { t: translate } = useTranslation();
+
+ const fileExtensions = [
+ { value: ".nii.gz", label: ".nii.gz" },
+ { value: ".zip", label: ".zip" },
+ ];
+
+ const [selectedExtension, setSelectedExtension] = useState(
+ fileExtensions[0].value,
+ );
+
+ const newDataUri = useMemo(
+ () => `${newAnnotationURIPrefix}${selectedExtension}`,
+ [newAnnotationURIPrefix, selectedExtension],
+ );
+
+ const createFamilyForNewAnnotation = (
+ layer: ILayer | undefined,
+ annotation: MiaAnnotation | undefined,
+ ) => {
+ const document = store?.editor.activeDocument;
+ if (document && layer) {
+ const layerFamily = new LayerFamily(undefined, document);
+ document.addLayerFamily(layerFamily);
+ if (annotation) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layerFamily.title = annotation.dataUri.split("/").pop() ?? "family :)";
+ layerFamily.metadata = {
+ ...annotation,
+ backend: "mia",
+ kind: "annotation",
+ };
+ }
+ const familyLayers = layer.getFamilyLayers();
+ familyLayers.forEach((l) => layerFamily.addLayer(l.id));
+ return layerFamily;
+ }
+ };
+
+ const createFileForFamilyOf = async (
+ layer: ILayer | undefined,
+ asZip: boolean,
+ ): Promise => {
+ if (layer?.isAnnotation) {
+ const layersToSave = layer.getFamilyLayers();
+ const file = await store?.editor?.activeDocument?.createFileFromLayers(
+ layersToSave,
+ asZip,
+ newAnnotationURIPrefix.split("/").pop() ?? "annotation",
+ );
+ return file;
+ }
+ };
+
+ const checkAnnotationURI = (file: File, uri: string) => {
+ if (path.extname(uri) !== path.extname(file.name)) {
+ throw new Error(
+ translate("uri-file-type-mismatch", { name: path.extname(file.name) }),
+ );
+ }
+ };
+
+ 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?.family?.metadata as MiaAnnotation;
+ return !!annotation;
+ }, [store]);
+
+ const saveAnnotation = async () => {
+ store?.setProgress({ labelTx: "saving" });
+ try {
+ const activeLayer = store?.editor.activeDocument?.activeLayer;
+ const annotationMeta = activeLayer?.family?.metadata as MiaAnnotation;
+ const annotationFile = await createFileForFamilyOf(
+ activeLayer,
+ annotationMeta?.dataUri.endsWith(".zip"),
+ );
+ if (!annotationMeta || !annotationFile) {
+ throw new Error(translate("create-annotation-error"));
+ }
+ checkAnnotationURI(annotationFile, annotationMeta.dataUri);
+ await store?.reviewStrategy?.currentTask?.updateAnnotation(
+ annotationMeta.id,
+ [annotationFile],
+ );
+ activeLayer?.getFamilyLayers().forEach((layer) => {
+ store?.editor.activeDocument?.history?.updateCheckpoint(layer.id);
+ });
+ return true;
+ } catch (error) {
+ if (error instanceof AxiosError) {
+ const description = error.response?.data?.message
+ ? error.response.data.message
+ : error.message
+ ? error.message
+ : "annotation-saving-error";
+ store?.setError({
+ titleTx: "saving-error",
+ descriptionTx: description,
+ });
+ }
+ throw error;
+ } finally {
+ store?.setProgress();
+ }
+ };
+
+ const loadOldAnnotation = async (
+ newAnnotationMeta: MiaAnnotation,
+ oldAnnotationMeta: MiaAnnotation,
+ ) => {
+ const activeLayer = store?.editor.activeDocument?.activeLayer;
+ if (activeLayer?.family) {
+ activeLayer.family.metadata = {
+ ...newAnnotationMeta,
+ backend: "mia",
+ kind: "annotation",
+ };
+ activeLayer.family.title = newAnnotationMeta.dataUri;
+ activeLayer.family.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 {
+ const activeLayer = store?.editor.activeDocument?.activeLayer;
+ const reviewTask = store?.reviewStrategy?.currentTask;
+ const annotationFile = (await createFileForFamilyOf(
+ activeLayer,
+ uri.endsWith(".zip"),
+ )) as FileWithMetadata;
+ if (!reviewTask || !annotationFile) {
+ throw new Error(translate("create-annotation-error"));
+ }
+ annotationFile.metadata = {
+ id: "",
+ dataUri: uri,
+ backend: "mia",
+ kind: "annotation",
+ };
+ const newAnnotationId = await reviewTask.createAnnotation([
+ annotationFile,
+ ]);
+ const annotationMeta = activeLayer?.family?.metadata as MiaAnnotation;
+ const newAnnotationMeta =
+ reviewTask instanceof MiaReviewTask
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ reviewTask.getAnnotation(newAnnotationId)!
+ : annotationMeta;
+ if (!annotationMeta) {
+ createFamilyForNewAnnotation(activeLayer, newAnnotationMeta);
+ } else {
+ await loadOldAnnotation(newAnnotationMeta, annotationMeta);
+ }
+ activeLayer?.getFamilyLayers().forEach((layer) => {
+ store?.editor.activeDocument?.history?.updateCheckpoint(layer.id);
+ });
+ store?.setProgress();
+ return true;
+ } catch (error) {
+ let description = "annotation-saving-error";
+ if (error instanceof AxiosError) {
+ description =
+ error.response?.data?.message ?? error.message ?? description;
+ }
+ store?.setError({
+ titleTx: "saving-error",
+ descriptionTx: description,
+ });
+ store?.setProgress();
+ }
+ };
+
+ const getImageName = (metadata?: BackendMetadata) =>
+ metadata && isMiaImageMetadata(metadata) && metadata?.dataUri
+ ? path.basename(metadata.dataUri).split(".")[0]
+ : "image";
+
+ const getAnnotationURIPrefixSuggestion = useCallback(() => {
+ const activeLayer = store?.editor.activeDocument?.activeLayer;
+ if (!activeLayer) {
+ return "annotation";
+ }
+ 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("_");
+ return `/annotations/${imageName}/${
+ layerNameWithIndexedName || "annotation"
+ }`;
+ }, [store]);
+
+ useEffect(() => {
+ if (isOpen) {
+ setnewAnnotationURIPrefix(getAnnotationURIPrefixSuggestion());
+ }
+ }, [isOpen, getAnnotationURIPrefixSuggestion]);
+
+ const isValidDataUri = useCallback(
+ (dataUri, allowedExtensions = [".nii.gz", ".zip"]) => {
+ const extensionsPattern = `(${allowedExtensions.join("|")})`;
+
+ const pattern = new RegExp(
+ `^((/|./)?([a-zA-Z0-9-_]+/)*([a-zA-Z0-9-_]+)${extensionsPattern})$`,
+ );
+
+ return pattern.test(dataUri)
+ ? "valid"
+ : translate("data_uri_help_message");
+ },
+ [translate],
+ );
+
+ const isValidAnnotationUri = useMemo(
+ () => isValidDataUri(newDataUri),
+ [newDataUri, isValidDataUri],
+ );
+
+ return (
+
+
+
+ {canBeOverwritten() && (
+ <>
+
+
+
+ {
+ if (await saveAnnotation()) {
+ onClose?.();
+ }
+ }}
+ />
+
+ >
+ )}
+
+ {isValidAnnotationUri !== "valid" && }
+
+
+ setSelectedExtension(value)}
+ size="medium"
+ borderRadius="default"
+ isDisableMixin
+ />
+ {
+ if (await saveAnnotationAs(newDataUri)) {
+ onClose?.();
+ }
+ }}
+ isDisabled={isValidAnnotationUri !== "valid"}
+ />
+
+
+ );
+});
diff --git a/apps/editor/src/components/editor/settings-popup/settings-popup.tsx b/apps/editor/src/components/editor/settings-popup/settings-popup.tsx
index 0c618650a..fbc1e2254 100644
--- a/apps/editor/src/components/editor/settings-popup/settings-popup.tsx
+++ b/apps/editor/src/components/editor/settings-popup/settings-popup.tsx
@@ -8,11 +8,13 @@ import {
LargePopUpGroup,
LargePopUpGroupTitle,
LargePopUpGroupTitleContainer,
+ PerformanceMode,
PopUp,
+ SupportedLanguage,
Switch,
Theme,
- useTranslation,
} from "@visian/ui-shared";
+import { VoxelInfoMode } from "@visian/utils";
import { observer } from "mobx-react-lite";
import React, { useCallback } from "react";
import styled, { useTheme } from "styled-components";
@@ -50,20 +52,27 @@ const voxelInfoSwitchOptions = [
export const SettingsPopUp: React.FC = observer(
({ isOpen, onClose }) => {
const store = useStore();
+
// Menu Actions
- const setTheme = useCallback(
- (value: string) => {
- store?.setColorMode(value as ColorMode);
- },
+ const setColorMode = useCallback(
+ (value: ColorMode) => store?.settings.setColorMode(value),
[store],
);
-
- const { i18n } = useTranslation();
const setLanguage = useCallback(
- (language: string) => {
- i18n.changeLanguage(language);
- },
- [i18n],
+ (language: SupportedLanguage) => store?.settings.setLanguage(language),
+ [store],
+ );
+ const setUseExclusiveSegmentations = useCallback(
+ (value: boolean) => store?.settings.setUseExclusiveSegmentations(value),
+ [store],
+ );
+ const setVoxelInfoMode = useCallback(
+ (mode: VoxelInfoMode) => store?.settings.setVoxelInfoMode(mode),
+ [store],
+ );
+ const setPerformanceMode = useCallback(
+ (mode: PerformanceMode) => store?.settings.setPerformanceMode(mode),
+ [store],
);
const theme = useTheme() as Theme;
@@ -84,13 +93,13 @@ export const SettingsPopUp: React.FC = observer(
@@ -98,10 +107,8 @@ export const SettingsPopUp: React.FC = observer(
labelTx="exclusive-segmentations"
infoTx="info-exclusive-segmentations"
infoBaseZIndex={theme.zIndices.overlay}
- value={store?.editor.activeDocument?.useExclusiveSegmentations}
- setValue={
- store?.editor.activeDocument?.setUseExclusiveSegmentations
- }
+ value={store?.settings.useExclusiveSegmentations}
+ setValue={setUseExclusiveSegmentations}
/>
= observer(
}
infoBaseZIndex={theme.zIndices.overlay}
options={voxelInfoSwitchOptions}
- value={store?.editor.activeDocument?.viewport2D.voxelInfoMode}
- setValue={
- store?.editor.activeDocument?.viewport2D?.setVoxelInfoMode
- }
+ value={store?.settings.voxelInfoMode}
+ setValue={setVoxelInfoMode}
/>
diff --git a/apps/editor/src/components/editor/toolbar/tool-group.tsx b/apps/editor/src/components/editor/toolbar/tool-group.tsx
index a8032de11..114133274 100644
--- a/apps/editor/src/components/editor/toolbar/tool-group.tsx
+++ b/apps/editor/src/components/editor/toolbar/tool-group.tsx
@@ -25,137 +25,137 @@ export const ToolGroup = observer<
},
HTMLButtonElement
>(
- ({ toolGroup, canExpand = true, onPress, onRelease, showTooltip }, ref) => {
- const store = useStore();
+ React.forwardRef(
+ ({ toolGroup, canExpand = true, onPress, onRelease, showTooltip }, ref) => {
+ const store = useStore();
- const [innerToolRef, setInnerToolRef] = useState(
- null,
- );
- const toolRef = useMultiRef(ref, setInnerToolRef);
+ const [innerToolRef, setInnerToolRef] =
+ useState(null);
+ const toolRef = useMultiRef(ref, setInnerToolRef);
- const [isHovered, setIsHovered] = useState(false);
- const hover = useCallback(() => {
- setIsHovered(true);
- }, []);
- const unhover = useCallback(() => {
- setIsHovered(false);
- }, []);
+ const [isHovered, setIsHovered] = useState(false);
+ const hover = useCallback(() => {
+ setIsHovered(true);
+ }, []);
+ const unhover = useCallback(() => {
+ setIsHovered(false);
+ }, []);
- const [isExpanded, setIsExpanded] = useState(false);
- const expand = useCallback(() => {
- setIsExpanded(true);
- }, []);
- const dismiss = useCallback(() => {
- setIsExpanded(false);
- }, []);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const expand = useCallback(() => {
+ setIsExpanded(true);
+ }, []);
+ const dismiss = useCallback(() => {
+ setIsExpanded(false);
+ }, []);
- // Short tap on active tool opens settings
- const [startTap, stopTap] = useShortTap(
- useCallback(
- (event: React.PointerEvent) => {
- if (event.button === PointerButton.LMB) {
- store?.editor.activeDocument?.tools.setShowToolSettings(true);
- }
- },
- [store],
- ),
- undefined,
- toolGroup.activeTool.isActive &&
- !(toolGroup.activeTool instanceof SelfDeactivatingTool) &&
- !store?.editor.activeDocument?.tools.showToolSettings,
- );
+ // Short tap on active tool opens settings
+ const [startTap, stopTap] = useShortTap(
+ useCallback(
+ (event: React.PointerEvent) => {
+ if (event.button === PointerButton.LMB) {
+ store?.editor.activeDocument?.tools.setShowToolSettings(true);
+ }
+ },
+ [store],
+ ),
+ undefined,
+ toolGroup.activeTool.isActive &&
+ !(toolGroup.activeTool instanceof SelfDeactivatingTool) &&
+ !store?.editor.activeDocument?.tools.showToolSettings,
+ );
- // Short tap on inactive tool makes it active
- const [startTap2, stopTap2] = useShortTap(
- useCallback(
- (event: React.PointerEvent) => {
- if (event.button === PointerButton.LMB) {
- store?.editor.activeDocument?.tools.setActiveTool(
- toolGroup.activeTool,
- );
- }
- },
- [store, toolGroup],
- ),
- undefined,
- !toolGroup.activeTool.isActive,
- );
+ // Short tap on inactive tool makes it active
+ const [startTap2, stopTap2] = useShortTap(
+ useCallback(
+ (event: React.PointerEvent) => {
+ if (event.button === PointerButton.LMB) {
+ store?.editor.activeDocument?.tools.setActiveTool(
+ toolGroup.activeTool,
+ );
+ }
+ },
+ [store, toolGroup],
+ ),
+ undefined,
+ !toolGroup.activeTool.isActive,
+ );
- // Long press expands group
- const [startPress, stopPress] = useLongPress(
- useCallback(
- (event: React.PointerEvent) => {
- if (
- event.button === PointerButton.LMB &&
- toolGroup.tools.length > 1
- ) {
- setIsExpanded(true);
- }
- },
- [toolGroup],
- ),
- undefined,
- canExpand && !isExpanded,
- );
+ // Long press expands group
+ const [startPress, stopPress] = useLongPress(
+ useCallback(
+ (event: React.PointerEvent) => {
+ if (
+ event.button === PointerButton.LMB &&
+ toolGroup.tools.length > 1
+ ) {
+ setIsExpanded(true);
+ }
+ },
+ [toolGroup],
+ ),
+ undefined,
+ canExpand && !isExpanded,
+ );
- const handlePointerDown = useForwardEvent(
- startTap,
- startTap2,
- startPress,
- useCallback(
- (event: React.PointerEvent) => {
- if (
- event.button === PointerButton.RMB &&
- toolGroup.tools.length > 1
- ) {
- setIsExpanded(canExpand && !isExpanded);
- }
- },
- [canExpand, isExpanded, toolGroup],
- ),
- );
- const handlePointerUp = useForwardEvent(stopTap, stopTap2, stopPress);
+ const handlePointerDown = useForwardEvent(
+ startTap,
+ startTap2,
+ startPress,
+ useCallback(
+ (event: React.PointerEvent) => {
+ if (
+ event.button === PointerButton.RMB &&
+ toolGroup.tools.length > 1
+ ) {
+ setIsExpanded(canExpand && !isExpanded);
+ }
+ },
+ [canExpand, isExpanded, toolGroup],
+ ),
+ );
+ const handlePointerUp = useForwardEvent(stopTap, stopTap2, stopPress);
- const activateTool = useCallback(
- (value: string | number | undefined) => {
- toolGroup.setActiveTool(value as ToolName);
- setIsExpanded(false);
- store?.editor.activeDocument?.tools.setIsCursorOverFloatingUI(false);
- },
- [toolGroup, store],
- );
+ const activateTool = useCallback(
+ (value: string | number | undefined) => {
+ toolGroup.setActiveTool(value as ToolName);
+ setIsExpanded(false);
+ store?.editor.activeDocument?.tools.setIsCursorOverFloatingUI(false);
+ },
+ [toolGroup, store],
+ );
- const hasActivateableTool = toolGroup.tools.some((tool) =>
- tool.canActivate(),
- );
- return hasActivateableTool ? (
- <>
-
- 1}
- expandHint={isHovered}
- isOpen={isExpanded}
- anchor={innerToolRef}
- position="right"
- onPressHint={expand}
- onOutsidePress={dismiss}
- >
- {toolGroup.tools.map((tool) => (
-
- ))}
-
- >
- ) : null;
- },
- { forwardRef: true },
+ const hasActivateableTool = toolGroup.tools.some((tool) =>
+ tool.canActivate(),
+ );
+ return hasActivateableTool ? (
+ <>
+
+ 1}
+ expandHint={isHovered}
+ isOpen={isExpanded}
+ anchor={innerToolRef}
+ position="right"
+ onPressHint={expand}
+ onOutsidePress={dismiss}
+ >
+ {toolGroup.tools.map((tool) => (
+
+ ))}
+
+ >
+ ) : null;
+ },
+ ),
);
diff --git a/apps/editor/src/components/editor/toolbar/tool.tsx b/apps/editor/src/components/editor/toolbar/tool.tsx
index b1c219d09..7ab1b6613 100644
--- a/apps/editor/src/components/editor/toolbar/tool.tsx
+++ b/apps/editor/src/components/editor/toolbar/tool.tsx
@@ -16,7 +16,7 @@ export const Tool = observer<
},
HTMLButtonElement
>(
- ({ tool, ...rest }, ref) => {
+ React.forwardRef(({ tool, ...rest }, ref) => {
const { t } = useTranslation();
const keys = tool.activationKeys && tool.activationKeys.split(",")[0];
@@ -41,6 +41,5 @@ export const Tool = observer<
onContextMenu={preventDefault}
/>
);
- },
- { forwardRef: true },
+ }),
);
diff --git a/apps/editor/src/components/editor/top-console/top-console.tsx b/apps/editor/src/components/editor/top-console/top-console.tsx
index 682e898b1..525688af9 100644
--- a/apps/editor/src/components/editor/top-console/top-console.tsx
+++ b/apps/editor/src/components/editor/top-console/top-console.tsx
@@ -1,4 +1,5 @@
-import { color, InvisibleButton, Text } from "@visian/ui-shared";
+import { color, InvisibleButton, StatusBadge, Text } from "@visian/ui-shared";
+import { MiaAnnotationMetadata } from "@visian/utils";
import { observer } from "mobx-react-lite";
import React from "react";
import styled from "styled-components";
@@ -10,6 +11,7 @@ const TopConsoleContainer = styled.div`
align-self: stretch;
display: flex;
justify-content: center;
+ flex-direction: column;
margin: 0 12px;
overflow: hidden;
padding-bottom: 8px;
@@ -46,20 +48,65 @@ const UnsavedChangesIndicator = styled(InvisibleButton)<{ isDirty?: boolean }>`
}
`;
+const TopRow = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-bottom: 8px;
+ position: relative;
+`;
+
export const TopConsole = observer(() => {
const store = useStore();
return store?.editor.activeDocument ? (
-
-
-
-
+ store?.reviewStrategy?.currentTask ? (
+
+
+
+
+
+ {(
+ store?.editor.activeDocument?.activeLayer?.family
+ ?.metadata as MiaAnnotationMetadata
+ )?.verified && (
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+ )
) : null;
});
diff --git a/apps/editor/src/components/editor/ui-overlay/ui-overlay.tsx b/apps/editor/src/components/editor/ui-overlay/ui-overlay.tsx
index acdf7614b..2a6f397e9 100644
--- a/apps/editor/src/components/editor/ui-overlay/ui-overlay.tsx
+++ b/apps/editor/src/components/editor/ui-overlay/ui-overlay.tsx
@@ -6,27 +6,30 @@ import {
Spacer,
Text,
} from "@visian/ui-shared";
-import { isFromWHO } from "@visian/utils";
+import { isFromMia, isFromWHO } from "@visian/utils";
import { observer } from "mobx-react-lite";
import React, { useCallback, useEffect, useRef, useState } from "react";
import styled from "styled-components";
import { useStore } from "../../../app/root-store";
import { whoHome } from "../../../constants";
+import { TaskType } from "../../../models/review-strategy";
import {
DilateErodeModal,
MeasurementModal,
SmartBrush3DModal,
ThresholdAnnotationModal,
} from "../action-modal";
-import { AIBar } from "../ai-bar";
import { AxesAndVoxel } from "../axes-and-voxel";
-import { DropSheet } from "../drop-sheet";
+import { ExportPopUp } from "../export-popup";
+import { ImageImportDropSheet } from "../import-image-drop-sheet";
import { ImportPopUp } from "../import-popup";
import { Layers } from "../layers";
import { MeasurementPopUp } from "../measurement-popup";
import { Menu } from "../menu";
import { ProgressPopUp } from "../progress-popup";
+import { MiaReviewBar, WhoReviewBar } from "../review-bar";
+import { SavePopUp } from "../save-popup";
import { ServerPopUp } from "../server-popup";
import { SettingsPopUp } from "../settings-popup";
import { ShortcutPopUp } from "../shortcut-popup";
@@ -182,19 +185,23 @@ export const UIOverlay = observer(
store?.editor.activeDocument?.tools.setIsCursorOverFloatingUI(false);
}, [store]);
- // Export Button
- const exportZip = useCallback(
- (event: React.PointerEvent) => {
- store?.setProgress({ labelTx: "exporting" });
- store?.editor.activeDocument
- ?.exportZip(event.shiftKey)
- .catch()
- .then(() => {
- store?.setProgress();
- });
- },
- [store],
- );
+ // Save Pop Up Toggling
+ const [isSavePopUpOpen, setIsSavePopUpOpen] = useState(false);
+ const openSavePopUp = useCallback(() => {
+ setIsSavePopUpOpen(true);
+ }, []);
+ const closeSavePopUp = useCallback(() => {
+ setIsSavePopUpOpen(false);
+ }, []);
+
+ // Export Pop Up Toggling
+ const [isExportPopUpOpen, setIsExportPopUpOpen] = useState(false);
+ const openExportPopUp = useCallback(() => {
+ setIsExportPopUpOpen(true);
+ }, []);
+ const closeExportPopUp = useCallback(() => {
+ setIsExportPopUpOpen(false);
+ }, []);
return (
(
<>
- {isFromWHO() ? (
-
-
-
- ) : (
+
+
+
+ {isFromWHO() ? (
+
- )}
-
-
-
+
+ ) : (
+
+ )}
@@ -258,26 +265,62 @@ export const UIOverlay = observer(
{!isFromWHO() && (
-
+ <>
+ {
+ await store?.reviewStrategy?.saveTask();
+ await store.redirectToReturnUrl({
+ forceRedirect: false,
+ });
+ }}
+ isActive={false}
+ />
+ {store?.reviewStrategy?.currentTask?.kind ===
+ TaskType.Create && (
+
+ )}
+ layer.isAnnotation,
+ )
+ }
+ tooltipTx="export-tooltip"
+ tooltipPosition="left"
+ onPointerDown={
+ store?.editor.activeDocument?.viewSettings.viewMode ===
+ "2D"
+ ? openExportPopUp
+ : store?.editor.activeDocument?.viewport3D
+ .exportCanvasImage
+ }
+ isActive={false}
+ />
+ >
)}
-
- {isFromWHO() && }
+ {isFromWHO() && }
+ {isFromMia() &&
+ store?.reviewStrategy?.currentTask?.kind === TaskType.Review && (
+
+ )}
(
)}
onClose={store?.editor.activeDocument?.setMeasurementDisplayLayer}
/>
- {isDraggedOver && }
+
+
+ {isDraggedOver && (
+
+ )}
{store?.progress && (
{
tooltipTx="undo"
tooltipPosition="right"
isActive={false}
- isDisabled={!store?.editor.activeDocument?.history.canUndo}
- onPointerDown={store?.editor.activeDocument?.history.undo}
+ isDisabled={!store?.editor.activeDocument?.canUndo()}
+ onPointerDown={() => {
+ store?.editor.activeDocument?.undo();
+ }}
/>
{
+ store?.editor.activeDocument?.redo();
+ }}
/>
diff --git a/apps/editor/src/components/index.ts b/apps/editor/src/components/index.ts
new file mode 100644
index 000000000..8b6442d76
--- /dev/null
+++ b/apps/editor/src/components/index.ts
@@ -0,0 +1,2 @@
+export * from "./editor";
+export * from "./data-manager";
diff --git a/apps/editor/src/event-handling/hotkeys.ts b/apps/editor/src/event-handling/hotkeys.ts
index aca4c88e1..297eb4c4a 100644
--- a/apps/editor/src/event-handling/hotkeys.ts
+++ b/apps/editor/src/event-handling/hotkeys.ts
@@ -83,14 +83,14 @@ export const generalHotkeys: IHotkey[] = [
// Undo/Redo
{
keys: "ctrl+z",
- action: (store) => store.editor.activeDocument?.history.undo(),
+ action: (store) => store.editor.activeDocument?.undo(),
labelTx: "undo",
name: "undo",
shortcutGuideSection: "undo-redo",
},
{
keys: "ctrl+shift+z,ctrl+y",
- action: (store) => store.editor.activeDocument?.history.redo(),
+ action: (store) => store.editor.activeDocument?.redo(),
labelTx: "redo",
name: "redo",
shortcutGuideSection: "undo-redo",
@@ -100,9 +100,7 @@ export const generalHotkeys: IHotkey[] = [
{
keys: "m",
action: (store) =>
- store.editor.activeDocument?.activeLayer?.setIsVisible(
- !store.editor.activeDocument.activeLayer.isVisible,
- ),
+ store.editor.activeDocument?.activeLayer?.tryToggleIsVisible(),
labelTx: "toggle-active-layer",
name: "toggle-active-layer",
shortcutGuideSection: "layer-controls",
@@ -366,7 +364,7 @@ export const generalHotkeys: IHotkey[] = [
action: (store) => {
store.setProgress({ labelTx: "exporting" });
store.editor.activeDocument
- ?.exportZip(true)
+ ?.exportZip(store.editor.activeDocument.layers, true)
.catch()
.then(() => {
store?.setProgress();
diff --git a/apps/editor/src/event-handling/index.ts b/apps/editor/src/event-handling/index.ts
index 615422dc6..7e5a249d8 100644
--- a/apps/editor/src/event-handling/index.ts
+++ b/apps/editor/src/event-handling/index.ts
@@ -9,7 +9,17 @@ import { setUpWheelHandling } from "./wheel";
export const setUpEventHandling = (
store: RootStore,
): [IDispatch, IDisposer] => {
- setUpHotKeys(store);
- setUpWheelHandling(store);
- return setUpPointerHandling(store);
+ const disposeHotkeys = setUpHotKeys(store);
+ const disposeWheelHandling = setUpWheelHandling(store);
+ const [dispatchPointerHandling, diposePointerHandling] =
+ setUpPointerHandling(store);
+
+ return [
+ dispatchPointerHandling,
+ () => {
+ disposeHotkeys();
+ disposeWheelHandling();
+ diposePointerHandling();
+ },
+ ];
};
diff --git a/apps/editor/src/import-handling/import-handling.ts b/apps/editor/src/import-handling/import-handling.ts
index 08d8d8b0f..3290462bf 100644
--- a/apps/editor/src/import-handling/import-handling.ts
+++ b/apps/editor/src/import-handling/import-handling.ts
@@ -55,7 +55,7 @@ const handleImportWithErrors = async (
store.editor.activeDocument?.finishBatchImport();
};
-export const importFilesToDocument = (
+export const importFilesToDocument = async (
files: FileList | DataTransferItemList,
store: RootStore,
// eslint-disable-next-line default-param-last
@@ -65,7 +65,7 @@ export const importFilesToDocument = (
if (!files.length) return;
store.setProgress({ labelTx: "importing", showSplash: true });
- handleImportWithErrors(files, store, shouldRetry).then(() => {
+ await handleImportWithErrors(files, store, shouldRetry).then(() => {
store.setProgress();
handleFinishedImport?.();
});
diff --git a/apps/editor/src/models/editor/document.ts b/apps/editor/src/models/editor/document.ts
index 6925fc31e..22969f5d9 100644
--- a/apps/editor/src/models/editor/document.ts
+++ b/apps/editor/src/models/editor/document.ts
@@ -6,8 +6,11 @@ import {
IEditor,
IImageLayer,
ILayer,
+ ILayerFamily,
ISliceRenderer,
IVolumeRenderer,
+ LayerFamilySnapshot,
+ LayerSnapshot,
MeasurementType,
PerformanceMode,
Theme,
@@ -15,13 +18,18 @@ import {
ValueType,
} from "@visian/ui-shared";
import {
+ BackendMetadata,
+ FileWithFamily,
+ FileWithMetadata,
handlePromiseSettledResult,
IDisposable,
ImageMismatchError,
ISerializable,
+ isMiaMetadata,
ITKImageWithUnit,
ITKMatrix,
readMedicalImage,
+ writeSingleMedicalImage,
Zip,
} from "@visian/utils";
import FileSaver from "file-saver";
@@ -43,7 +51,8 @@ import { readTrackingLog, TrackingData } from "../tracking";
import { StoreContext } from "../types";
import { Clipboard } from "./clipboard";
import { History, HistorySnapshot } from "./history";
-import { ImageLayer, Layer, LayerSnapshot } from "./layers";
+import { LayerFamily } from "./layer-families";
+import { ImageLayer } from "./layers";
import * as layers from "./layers";
import { Markers } from "./markers";
import { ToolName, Tools, ToolsSnapshot } from "./tools";
@@ -73,6 +82,7 @@ export interface DocumentSnapshot {
activeLayerId?: string;
layerMap: LayerSnapshot[];
layerIds: string[];
+ layerFamilies: LayerFamilySnapshot[];
history: HistorySnapshot;
@@ -95,7 +105,8 @@ export class Document
protected activeLayerId?: string;
protected measurementDisplayLayerId?: string;
- protected layerMap: { [key: string]: Layer };
+ protected layerMap: { [key: string]: ILayer };
+ protected layerFamilyMap: { [key: string]: LayerFamily };
protected layerIds: string[];
public measurementType: MeasurementType = "volume";
@@ -126,10 +137,10 @@ export class Document
this.titleOverride = snapshot?.titleOverride;
this.activeLayerId = snapshot?.activeLayerId;
this.layerMap = {};
+ this.layerFamilyMap = {};
snapshot?.layerMap.forEach((layer) => {
const LayerKind = layerMap[layer.kind];
if (!LayerKind) return;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
this.layerMap[layer.id] = new LayerKind(layer as any, this);
});
this.layerIds = snapshot?.layerIds || [];
@@ -138,6 +149,9 @@ export class Document
layer.fixPotentiallyBadColor(),
);
+ snapshot?.layerFamilies.forEach((family) => {
+ this.layerFamilyMap[family.id] = new LayerFamily(family, this);
+ });
this.history = new History(snapshot?.history, this);
this.viewSettings = new ViewSettings(snapshot?.viewSettings, this);
this.viewport2D = new Viewport2D(snapshot?.viewport2D, this);
@@ -153,6 +167,7 @@ export class Document
| "activeLayerId"
| "measurementDisplayLayerId"
| "layerMap"
+ | "layerFamilyMap"
| "layerIds"
>(this, {
id: observable,
@@ -160,6 +175,7 @@ export class Document
activeLayerId: observable,
measurementDisplayLayerId: observable,
layerMap: observable,
+ layerFamilyMap: observable,
layerIds: observable,
measurementType: observable,
history: observable,
@@ -172,13 +188,17 @@ export class Document
useExclusiveSegmentations: observable,
title: computed,
+ layers: computed,
+ renderingOrder: computed,
+ flatRenderingOrder: computed,
+ layerFamilies: computed,
activeLayer: computed,
measurementDisplayLayer: computed,
imageLayers: computed,
mainImageLayer: computed,
annotationLayers: computed,
- maxLayers: computed,
- maxLayers3d: computed,
+ maxVisibleLayers: computed,
+ maxVisibleLayers3d: computed,
setTitle: action,
setActiveLayer: action,
@@ -186,7 +206,6 @@ export class Document
setMeasurementType: action,
addLayer: action,
addNewAnnotationLayer: action,
- moveLayer: action,
deleteLayer: action,
toggleTypeAndRepositionLayer: action,
importImage: action,
@@ -196,6 +215,7 @@ export class Document
toggleLayerMenu: action,
setUseExclusiveSegmentations: action,
applySnapshot: action,
+ getLayerFamily: action,
});
// This is split up to avoid errors from a tool that is being activated
@@ -208,12 +228,23 @@ export class Document
this.clipboard.dispose();
this.tools.dispose();
Object.values(this.layerMap).forEach((layer) => layer.delete());
+ // TODO dispose of layerFamilies
}
public get title(): string | undefined {
if (this.titleOverride) return this.titleOverride;
- const { length } = this.layerIds;
- return length ? this.getLayer(this.layerIds[length - 1])?.title : undefined;
+ const familyMeta = this.activeLayer?.family?.metadata;
+ if (isMiaMetadata(familyMeta)) {
+ return familyMeta.dataUri;
+ }
+ const { length } = this.layers;
+ if (!length) return undefined;
+ const lastLayer = this.getLayer(this.layerIds[length - 1]);
+ const layerMeta = lastLayer?.metadata;
+ if (isMiaMetadata(layerMeta)) {
+ return layerMeta?.dataUri?.split("/").pop();
+ }
+ return lastLayer?.title;
}
public setTitle = (value?: string): void => {
@@ -221,26 +252,55 @@ export class Document
};
// Layer Management
- public get maxLayers(): number {
+ public get maxVisibleLayers(): number {
return (this.renderer?.capabilities.maxTextures || 0) - generalTextures2d;
}
- public get maxLayers3d(): number {
+ public get maxVisibleLayers3d(): number {
return (this.renderer?.capabilities.maxTextures || 0) - generalTextures3d;
}
public get layers(): ILayer[] {
- return this.layerIds.map((id) => this.layerMap[id]);
+ return this.layerIds.flatMap((id) => {
+ if (this.layerFamilyMap[id]) {
+ return this.layerFamilyMap[id].layers;
+ }
+ return this.layerMap[id];
+ });
+ }
+
+ public get renderingOrder(): (ILayer | ILayerFamily)[] {
+ return this.layerIds.map((id) => {
+ if (this.layerFamilyMap[id]) {
+ return this.layerFamilyMap[id];
+ }
+ return this.layerMap[id];
+ });
+ }
+
+ public get flatRenderingOrder(): (ILayer | ILayerFamily)[] {
+ return this.layerIds.flatMap((id) => {
+ const family = this.layerFamilyMap[id];
+ if (family) {
+ const array: (ILayer | ILayerFamily)[] = [family as ILayerFamily];
+ return array.concat(family.layers);
+ }
+ return this.layerMap[id];
+ });
+ }
+
+ public get layerFamilies(): ILayerFamily[] {
+ return this.layerIds
+ .filter((id) => !!this.layerFamilyMap[id])
+ .map((id) => this.layerFamilyMap[id]);
}
public get activeLayer(): ILayer | undefined {
- return Object.values(this.layerMap).find(
- (layer) => layer.id === this.activeLayerId,
- );
+ return this.layers.find((layer) => layer.id === this.activeLayerId);
}
public get measurementDisplayLayer(): IImageLayer | undefined {
- return Object.values(this.layerMap).find(
+ return this.layers.find(
(layer) =>
layer.id === this.measurementDisplayLayerId && layer.kind === "image",
) as IImageLayer | undefined;
@@ -248,29 +308,26 @@ export class Document
public get imageLayers(): IImageLayer[] {
return this.layers.filter(
- (layer) => layer.kind === "image",
+ (layer) => layer.kind === "image" && layer.isVisible,
) as IImageLayer[];
}
- public get mainImageLayer(): IImageLayer | undefined {
- // TODO: Rework to work with group layers
+ public get mainImageLayer(): IImageLayer | undefined {
const areAllLayersAnnotations = Boolean(
- !this.layerIds.find((layerId) => {
- const layer = this.layerMap[layerId];
- return layer.kind === "image" && !layer.isAnnotation;
- }),
+ !this.layers.find(
+ (layer) => layer.kind === "image" && !layer.isAnnotation,
+ ),
);
const areAllImageLayersInvisible = Boolean(
- !this.layerIds.find((layerId) => {
- const layer = this.layerMap[layerId];
- return layer.kind === "image" && !layer.isAnnotation && layer.isVisible;
- }),
+ !this.layers.find(
+ (layer) =>
+ layer.kind === "image" && !layer.isAnnotation && layer.isVisible,
+ ),
);
let mainImageLayer: ImageLayer | undefined;
- this.layerIds.slice().find((id) => {
- const layer = this.layerMap[id];
+ this.layers.slice().find((layer) => {
if (
layer.kind === "image" &&
// use non-annotation layer if possible
@@ -295,6 +352,17 @@ export class Document
return id ? this.layerMap[id] : undefined;
}
+ public getOrphanAnnotationLayers(): ILayer[] {
+ const orphanAnnotationLayers = this.layers.filter(
+ (l) => l.isAnnotation && !l.family,
+ );
+ return orphanAnnotationLayers ?? [];
+ }
+
+ public getLayerFamily(id: string): ILayerFamily | undefined {
+ return this.layerFamilyMap[id];
+ }
+
public setActiveLayer = (idOrLayer?: string | ILayer): void => {
this.activeLayerId = idOrLayer
? typeof idOrLayer === "string"
@@ -314,24 +382,54 @@ export class Document
public setMeasurementType = (measurementType: MeasurementType) => {
this.measurementType = measurementType;
};
-
- public addLayer = (...newLayers: Layer[]): void => {
- newLayers.forEach((layer) => {
+ /** Ensures consistency of layerIds. addLayer should be called whenever a layer is moved or changes family
+ if the layer has a family, it will be removed from layerIds
+ if an index is specified, the family will be inserted at the index
+ if no index is specified, the family remain where it was if already in the list
+ if not in the list, annotations will be inserted at the start and images at the end of the list */
+ public addLayer = (layer: ILayer, index?: number): void => {
+ if (!layer.id) return;
+ if (!this.layerMap[layer.id]) {
this.layerMap[layer.id] = layer;
- if (layer.isAnnotation) {
- this.layerIds.unshift(layer.id);
- } else {
- // insert image layer after all annotation layers
- let insertIndex = 0;
- for (let i = this.layerIds.length - 1; i >= 0; i--) {
- if (this.layerMap[this.layerIds[i]].isAnnotation) {
- insertIndex = i + 1;
- break;
- }
- }
- this.layerIds.splice(insertIndex, 0, layer.id);
+ }
+ const oldIndex = this.layerIds.indexOf(layer.id);
+ if (layer.family) {
+ if (this.layerIds.includes(layer.id)) {
+ this.layerIds = this.layerIds.filter((id) => id !== layer.id);
}
- });
+ } else if (index !== undefined) {
+ if (oldIndex < 0) {
+ this.layerIds.splice(index, 0, layer.id);
+ } else if (index !== oldIndex) {
+ this.layerIds.splice(index, 0, this.layerIds.splice(oldIndex, 1)[0]);
+ }
+ } else if (layer.isAnnotation && oldIndex < 0) {
+ this.layerIds = this.layerIds.filter((id) => id !== layer.id);
+ this.layerIds.unshift(layer.id);
+ } else if (oldIndex < 0) {
+ this.layerIds = this.layerIds.filter((id) => id !== layer.id);
+ this.layerIds.push(layer.id);
+ }
+ };
+
+ // if an index is specified, the family will be inserted at the index
+ // if no index is specified, the family remain where it was if already in the list or inserted at the start
+ public addLayerFamily = (family: LayerFamily, idx?: number): void => {
+ if (!family.id) return;
+
+ if (!this.layerFamilyMap[family.id]) {
+ this.layerFamilyMap[family.id] = family;
+ }
+ const oldIndex = this.layerIds.indexOf(family.id);
+ if (idx !== undefined) {
+ if (oldIndex < 0) {
+ this.layerIds.splice(idx, 0, family.id);
+ } else if (idx !== oldIndex) {
+ this.layerIds.splice(idx, 0, this.layerIds.splice(oldIndex, 1)[0]);
+ }
+ } else if (oldIndex < 0) {
+ this.layerIds.unshift(family.id);
+ }
};
public getFirstUnusedColor = (
@@ -375,18 +473,11 @@ export class Document
this.viewSettings.setViewMode(this.viewSettings.viewMode);
};
- public moveLayer(idOrLayer: string | ILayer, newIndex: number) {
- const layerId = typeof idOrLayer === "string" ? idOrLayer : idOrLayer.id;
- const oldIndex = this.layerIds.indexOf(layerId);
- if (!~oldIndex) return;
-
- this.layerIds.splice(newIndex, 0, this.layerIds.splice(oldIndex, 1)[0]);
- }
-
public deleteLayer = (idOrLayer: string | ILayer): void => {
const layerId = typeof idOrLayer === "string" ? idOrLayer : idOrLayer.id;
this.layerIds = this.layerIds.filter((id) => id !== layerId);
+ this.layerMap[layerId].delete();
delete this.layerMap[layerId];
if (this.activeLayerId === layerId) {
this.setActiveLayer(this.layerIds[0]);
@@ -396,61 +487,115 @@ export class Document
/** Toggles the type of the layer (annotation or not) and repositions it accordingly */
public toggleTypeAndRepositionLayer = (idOrLayer: string | ILayer): void => {
const layerId = typeof idOrLayer === "string" ? idOrLayer : idOrLayer.id;
- let lastAnnotationIndex = this.layerIds.length - 1;
- for (let i = 0; i < this.layerIds.length; i++) {
- if (!this.layerMap[this.layerIds[i]].isAnnotation) {
- lastAnnotationIndex = i - 1;
- break;
- }
+ const layer = this.getLayer(layerId);
+ if (!layer) return;
+ if (layer.isAnnotation) {
+ layer.setFamily(undefined);
}
-
- if (this.layerMap[layerId].isAnnotation) {
- this.moveLayer(layerId, lastAnnotationIndex);
- } else {
- this.moveLayer(layerId, lastAnnotationIndex + 1);
- }
-
- this.layerMap[layerId].setIsAnnotation(
- !this.layerMap[layerId].isAnnotation,
- );
+ layer.setIsAnnotation(!layer.isAnnotation);
};
public get has3DLayers(): boolean {
- return Object.values(this.layerMap).some((layer) => layer.is3DLayer);
+ return this.layers.some((layer) => layer.is3DLayer);
}
- // I/O
- public exportZip = async (limitToAnnotations?: boolean) => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ protected zipLayers = async (layers: ILayer[]) => {
const zip = new Zip();
-
- // TODO: Rework for group layers
- const files = await Promise.all(
- this.layers
- .filter((layer) => !limitToAnnotations || layer.isAnnotation)
- .map((layer) => layer.toFile()),
- );
+ const files = await Promise.all(layers.map((layer) => layer.toFile()));
files.forEach((file, index) => {
if (!file) return;
zip.setFile(`${`00${index}`.slice(-2)}_${file.name}`, file);
});
+ return zip;
+ };
+
+ // 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),
+ );
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}.zip`);
+ FileSaver.saveAs(
+ await zip.toBlob(),
+ `${this.title?.split(".")[0] ?? "annotation"}.zip`,
+ );
+ };
+
+ public createZip = async (
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ layers: ILayer[],
+ title?: string,
+ ): Promise => {
+ const zip = await this.zipLayers(layers);
+
+ return new File(
+ [await zip.toBlob()],
+ `${title ?? this.title?.split(".")[0] ?? "annotation"}.zip`,
+ );
+ };
+
+ public createSquashedNii = async (
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ layers: ILayer[],
+ title?: string,
+ ): Promise => {
+ const imageLayers = this.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),
+ true,
+ ),
+ `${title ?? this.title?.split(".")[0] ?? "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);
+ if (file) {
+ const fileBlob = new Blob([file], { type: file.type });
+ FileSaver.saveAs(
+ await fileBlob,
+ `${this.title?.split(".")[0] ?? "annotaion"}.nii.gz`,
+ );
+ } else {
+ throw Error("export-error");
+ }
+ };
+
+ public createFileFromLayers = async (
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ layers: ILayer[],
+ asZip: boolean,
+ title?: string,
+ ): Promise => {
+ if (asZip) {
+ return this.createZip(layers, title);
+ }
+ return this.createSquashedNii(layers, title);
};
public getFileForLayer = async (idOrLayer: string | ILayer) => {
const layerId = typeof idOrLayer === "string" ? idOrLayer : idOrLayer.id;
- const layer = this.layerMap[layerId];
+ const layer = this.getLayer(layerId);
+ if (!layer) return;
const file = await layer.toFile();
return file;
};
public finishBatchImport() {
- if (!Object.values(this.layerMap).some((layer) => layer.isAnnotation)) {
+ if (!this.layers.some((layer) => layer.isAnnotation)) {
this.addNewAnnotationLayer();
this.viewport2D.setMainViewType();
}
@@ -567,7 +712,15 @@ export class Document
}
} else if (filteredFiles.name.endsWith(".zip")) {
const zip = await Zip.fromZipFile(filteredFiles);
- await this.importFiles(await zip.getAllFiles(), filteredFiles.name);
+ const unzippedFiles = await zip.getAllFiles();
+ await this.importFiles(
+ this.createLayerFamily(
+ unzippedFiles,
+ filteredFiles.name,
+ this.getMetadataFromFile(filteredFiles),
+ ),
+ filteredFiles.name,
+ );
return;
} else if (filteredFiles.name.endsWith(".json")) {
await readTrackingLog(filteredFiles, this);
@@ -576,17 +729,28 @@ export class Document
if (Array.isArray(filteredFiles) && !filteredFiles.length) return;
- if (this.layers.length >= this.maxLayers) {
- this.setError({
- titleTx: "import-error",
- descriptionTx: "too-many-layers-2d",
- descriptionData: { count: this.maxLayers },
- });
- return;
+ //! TODO: #513
+ // if (this.imageLayers.length >= this.maxVisibleLayers) {
+ // this.setError({
+ // titleTx: "import-error",
+ // descriptionTx: "too-many-layers-2d",
+ // descriptionData: { count: this.maxVisibleLayers },
+ // });
+ // return;
+ // }
+
+ if (filteredFiles instanceof File) {
+ this.createLayerFamily(
+ [filteredFiles],
+ filteredFiles.name,
+ this.getMetadataFromFile(filteredFiles),
+ );
+ } else {
+ this.createLayerFamily(filteredFiles, name ?? uuidv4());
}
-
let createdLayerId = "";
- const isFirstLayer = !this.layerIds.length;
+ const isFirstLayer =
+ !this.layerIds.length || !this.layers.some((l) => l.kind !== "group");
const image = await readMedicalImage(filteredFiles);
image.name =
name ||
@@ -683,6 +847,10 @@ export class Document
name: `${layerIndex}_${imageWithUnit.name}`,
...prototypeImage,
});
+ if (files instanceof File) {
+ this.addLayerToFamily(createdLayerId, files);
+ this.addMetadataToLayer(createdLayerId, files);
+ }
}
} else {
this.setError({
@@ -691,33 +859,19 @@ export class Document
});
}
} else {
- const numberOfAnnotations = uniqueValues.size - 1;
-
- if (
- numberOfAnnotations === 1 ||
- numberOfAnnotations + this.layers.length > this.maxLayers
- ) {
+ //! TODO: #513
+ uniqueValues.forEach(async (value) => {
+ if (value === 0) return;
createdLayerId = await this.importAnnotation(
- imageWithUnit,
- undefined,
- true,
+ { ...imageWithUnit, name: `${value}_${image.name}` },
+ value,
);
-
- if (numberOfAnnotations !== 1) {
- this.setError({
- titleTx: "squashed-layers-title",
- descriptionTx: "squashed-layers-import",
- });
+ if (files instanceof File) {
+ this.addLayerToFamily(createdLayerId, files);
+ this.addMetadataToLayer(createdLayerId, files);
}
- } else {
- uniqueValues.forEach(async (value) => {
- if (value === 0) return;
- createdLayerId = await this.importAnnotation(
- { ...imageWithUnit, name: `${value}_${image.name}` },
- value,
- );
- });
- }
+ });
+ // }
}
// Force switch to 2D if too many layers for 3D
@@ -731,6 +885,17 @@ export class Document
this.history.clear();
}
+ if (files instanceof File) {
+ this.addLayerToFamily(createdLayerId, files);
+ this.addMetadataToLayer(createdLayerId, files);
+ }
+
+ // Move all families with only image layers to the end of the list:
+ this.layerFamilies.forEach((family) => {
+ if (!family.layers.every((layer) => !layer.isAnnotation)) return;
+ this.addLayerFamily(family as LayerFamily, this.layerFamilies.length - 1);
+ });
+
return createdLayerId;
}
@@ -759,6 +924,7 @@ export class Document
const imageLayer = ImageLayer.fromITKImage(image, this, {
color: defaultImageColor,
+ isVisible: this.imageLayers.length < this.maxVisibleLayers,
});
if (
this.mainImageLayer &&
@@ -790,6 +956,7 @@ export class Document
{
isAnnotation: true,
color: this.getFirstUnusedColor(),
+ isVisible: this.imageLayers.length < this.maxVisibleLayers,
},
filterValue,
squash,
@@ -845,11 +1012,10 @@ export class Document
public getExcludedSegmentations(layer: ILayer) {
if (!this.useExclusiveSegmentations) return undefined;
- const layerIndex = this.layerIds.indexOf(layer.id);
+ const layerIndex = this.layers.indexOf(layer);
if (layerIndex <= 0) return undefined;
- return this.layerIds
+ return this.layers
.slice(0, layerIndex)
- .map((layerId) => this.layerMap[layerId])
.filter(
(potentialLayer) =>
potentialLayer.isAnnotation &&
@@ -859,6 +1025,68 @@ export class Document
) as unknown as IImageLayer[];
}
+ /** Adds layer to group specified in file object */
+ private addLayerToFamily(layerId: string, file: File) {
+ const layer = this.getLayer(layerId);
+ const family = this.getLayerFamilyFromFile(file);
+ if (layer && family) {
+ family?.addLayer(layer.id);
+ }
+ }
+
+ /** Adds meta data from file with metadata to layer */
+ private addMetadataToLayer(layerId: string, file: File) {
+ const layer = this.getLayer(layerId);
+ const metadata = this.getMetadataFromFile(file);
+ if (layer && metadata) {
+ layer.metadata = metadata;
+ }
+ }
+
+ /** Returns the group layer specified in the file object */
+ private getLayerFamilyFromFile(file: File): ILayerFamily | undefined {
+ if ("familyId" in file) {
+ const fileWithFamily = file as FileWithFamily;
+ return this.getLayerFamily(fileWithFamily.familyId);
+ }
+ return undefined;
+ }
+
+ /** Extracts metadata appended to a file object */
+ private getMetadataFromFile(file: File): BackendMetadata | undefined {
+ if ("metadata" in file) {
+ const fileWithMetadata = file as FileWithMetadata;
+ return fileWithMetadata.metadata;
+ }
+ return undefined;
+ }
+
+ /** Creates a LayerFamily object for a list of files and adds the group id to the files */
+ public createLayerFamily(
+ files: File[],
+ title?: string,
+ groupMetadata?: BackendMetadata,
+ ): FileWithFamily[] {
+ if (files.every((f) => "familyId" in f)) {
+ return files as FileWithFamily[];
+ }
+ if (files.some((f) => "familyId" in f)) {
+ throw new Error(
+ "Cannot create a new group for file that already belongs to a group",
+ );
+ }
+ const layerFamily = new LayerFamily({ title }, this);
+ layerFamily.metadata = groupMetadata;
+
+ const filesWithGroup = files.map((f) => {
+ const fileWithGroup = f as FileWithFamily;
+ fileWithGroup.familyId = layerFamily.id;
+ return fileWithGroup;
+ });
+ this.addLayerFamily(layerFamily);
+ return filesWithGroup;
+ }
+
// Proxies
public get sliceRenderer(): ISliceRenderer | undefined {
return this.editor.sliceRenderer;
@@ -890,7 +1118,7 @@ export class Document
id: this.id,
titleOverride: this.titleOverride,
activeLayerId: this.activeLayerId,
- layerMap: Object.values(this.layerMap).map((layer) => layer.toJSON()),
+ layerMap: this.layers.map((layer) => layer.toJSON()),
layerIds: toJS(this.layerIds),
history: this.history.toJSON(),
viewSettings: this.viewSettings.toJSON(),
@@ -898,6 +1126,7 @@ export class Document
viewport3D: this.viewport3D.toJSON(),
tools: this.tools.toJSON(),
useExclusiveSegmentations: this.useExclusiveSegmentations,
+ layerFamilies: this.layerFamilies.map((family) => family.toJSON()),
};
}
@@ -912,4 +1141,28 @@ export class Document
"This is a noop. To load a document from storage, create a new instance",
);
}
+
+ public canUndo(): boolean {
+ return this.activeLayer?.id
+ ? this.history.canUndo(this.activeLayer.id)
+ : false;
+ }
+
+ public canRedo(): boolean {
+ return this.activeLayer?.id
+ ? this.history.canRedo(this.activeLayer.id)
+ : false;
+ }
+
+ public undo(): void {
+ if (this.activeLayer?.id) {
+ this.history.undo(this.activeLayer.id);
+ }
+ }
+
+ public redo(): void {
+ if (this.activeLayer?.id) {
+ this.history.redo(this.activeLayer.id);
+ }
+ }
}
diff --git a/apps/editor/src/models/editor/editor.ts b/apps/editor/src/models/editor/editor.ts
index c2dc4c339..b4297691a 100644
--- a/apps/editor/src/models/editor/editor.ts
+++ b/apps/editor/src/models/editor/editor.ts
@@ -30,6 +30,8 @@ export interface EditorSnapshot {
activeDocument?: DocumentSnapshot;
performanceMode: PerformanceMode;
+
+ returnUrl?: string;
}
export class Editor
@@ -51,6 +53,8 @@ export class Editor
public performanceMode: PerformanceMode = "high";
+ public returnUrl?: string;
+
constructor(
snapshot: EditorSnapshot | undefined,
protected context: StoreContext,
@@ -62,12 +66,14 @@ export class Editor
volumeRenderer: observable,
performanceMode: observable,
isAvailable: observable,
+ returnUrl: observable,
colorMode: computed,
setActiveDocument: action,
setPerformanceMode: action,
applySnapshot: action,
+ setReturnUrl: action,
});
runInAction(() => {
@@ -97,6 +103,17 @@ export class Editor
this.renderer.dispose();
}
+ private applyGlobalSettings() {
+ this.context.getSettings().load();
+ this.activeDocument?.setUseExclusiveSegmentations(
+ this.context.getSettings().useExclusiveSegmentations,
+ );
+ this.activeDocument?.viewport2D.setVoxelInfoMode(
+ this.context.getSettings().voxelInfoMode,
+ );
+ this.setPerformanceMode(this.context.getSettings().performanceMode);
+ }
+
public setActiveDocument(
// eslint-disable-next-line default-param-last
value = new Document(undefined, this, this.context),
@@ -105,6 +122,8 @@ export class Editor
this.activeDocument?.dispose();
this.activeDocument = value;
+ this.applyGlobalSettings();
+
if (!isSilent) this.activeDocument.requestSave();
}
@@ -120,6 +139,17 @@ export class Editor
return false;
};
+ public disposeActiveDocument = () => {
+ if (
+ // eslint-disable-next-line no-alert
+ window.confirm(i18n.t("discard-current-document-confirmation"))
+ ) {
+ this.activeDocument?.dispose();
+ return true;
+ }
+ return false;
+ };
+
// Proxies
public get refs(): { [name: string]: React.RefObject } {
return this.context.getRefs();
@@ -130,7 +160,7 @@ export class Editor
}
public get colorMode(): ColorMode {
- return this.context.getColorMode();
+ return this.context.getSettings().colorMode;
}
// Performance Mode
@@ -143,6 +173,7 @@ export class Editor
return {
activeDocument: this.activeDocument?.toJSON(),
performanceMode: this.performanceMode,
+ returnUrl: this.returnUrl,
};
}
@@ -155,6 +186,7 @@ export class Editor
true,
);
this.setPerformanceMode(snapshot?.performanceMode);
+ this.setReturnUrl(snapshot?.returnUrl);
} else {
this.context.setError({
titleTx: "browser-error",
@@ -175,4 +207,8 @@ export class Editor
this.sliceRenderer?.animate();
this.volumeRenderer?.animate();
};
+
+ public setReturnUrl = (url?: string) => {
+ this.returnUrl = url;
+ };
}
diff --git a/apps/editor/src/models/editor/history/history.ts b/apps/editor/src/models/editor/history/history.ts
index 04788e815..8ea2649c8 100644
--- a/apps/editor/src/models/editor/history/history.ts
+++ b/apps/editor/src/models/editor/history/history.ts
@@ -6,9 +6,9 @@ import {
ValueType,
} from "@visian/ui-shared";
import {
+ CommandStack,
+ CommandStackSnapshot,
ISerializable,
- LimitedStack,
- LimitedStackSnapshot,
} from "@visian/utils";
import { action, makeObservable, observable } from "mobx";
@@ -23,7 +23,10 @@ Object.values(commands).forEach((command) => {
});
export interface HistorySnapshot {
- undoRedoStack: LimitedStackSnapshot;
+ commandStacks: {
+ layerId: string;
+ commandStack: CommandStackSnapshot;
+ }[];
}
export class History
@@ -31,9 +34,7 @@ export class History
{
public readonly excludeFromSnapshotTracking = ["document"];
- protected undoRedoStack = new LimitedStack(
- maxUndoRedoSteps,
- );
+ protected commandStacks = new Map>();
constructor(
snapshot: Partial | undefined,
@@ -41,8 +42,8 @@ export class History
) {
if (snapshot) this.applySnapshot(snapshot);
- makeObservable(this, {
- undoRedoStack: observable,
+ makeObservable(this, {
+ commandStacks: observable,
undo: action,
redo: action,
@@ -52,72 +53,115 @@ export class History
});
}
- public get canUndo(): boolean {
- return this.undoRedoStack.canNavigateBackward();
+ public canUndo(layerId: string) {
+ return this.commandStacks.get(layerId)?.canNavigateBackward() ?? false;
}
- public get canRedo(): boolean {
- return this.undoRedoStack.canNavigateForward();
+ public canRedo(layerId: string) {
+ return this.commandStacks.get(layerId)?.canNavigateForward() ?? false;
}
- public undo = (): void => {
- if (!this.canUndo) return;
+ public undo = (layerId: string) => {
+ if (!this.canUndo(layerId)) return;
// As we can undo, we can be sure that there is at least the current item
// in the stack.
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- this.undoRedoStack.getCurrent()!.undo();
- this.undoRedoStack.navigateBackward();
+ this.commandStacks.get(layerId)?.getCurrent()?.undo();
+ this.commandStacks.get(layerId)?.navigateBackward();
};
- public redo = (): void => {
- if (!this.canRedo) return;
+ public redo = (layerId: string) => {
+ if (!this.canRedo(layerId)) return;
// As we can redo, we can be sure that we can navigate forward
// in the stack.
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- this.undoRedoStack.navigateForward()!.redo();
+ this.commandStacks.get(layerId)?.navigateForward()?.redo();
};
- public addCommand(command: IUndoRedoCommand): void {
- this.undoRedoStack.push(command);
+ public addCommand(command: IUndoRedoCommand) {
+ const { layerId } = command;
+ if (!this.commandStacks.has(layerId)) {
+ this.commandStacks.set(
+ layerId,
+ new CommandStack(maxUndoRedoSteps),
+ );
+ }
+ this.commandStacks.get(layerId)?.push(command);
}
- public clear = (): void => {
- this.undoRedoStack.clear();
+ public clear = (layerId?: string) => {
+ if (layerId) {
+ this.commandStacks.delete(layerId);
+ } else {
+ this.commandStacks = new Map>();
+ }
};
// Serialization
public toJSON(): HistorySnapshot {
- const stackSnapshot = this.undoRedoStack.toJSON();
+ const stackSnapshot = [...this.commandStacks.entries()].map(
+ ([layerId, stack]) => {
+ const jsonStack = stack.toJSON();
+ return {
+ layerId,
+ commandStack: {
+ ...jsonStack,
+ buffer: jsonStack.buffer.map((command) => command.toJSON()),
+ },
+ };
+ },
+ );
return {
- undoRedoStack: {
- ...stackSnapshot,
- buffer: stackSnapshot.buffer.map((command) => command.toJSON()),
- },
+ commandStacks: stackSnapshot,
};
}
- public applySnapshot(snapshot: Partial): Promise {
- if (snapshot.undoRedoStack) {
- return this.undoRedoStack.applySnapshot({
- ...snapshot.undoRedoStack,
- buffer: snapshot.undoRedoStack.buffer
- .map((commandSnapshot) => {
- const Command = commandMap[commandSnapshot.kind];
- if (!Command) return;
- return new Command(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- commandSnapshot as any,
- this.document,
- );
- })
- .filter((command) => Boolean(command)) as IUndoRedoCommand[],
- });
+ public async applySnapshot(
+ snapshot: Partial,
+ ): Promise {
+ if (!snapshot.commandStacks) {
+ this.clear();
+ return;
}
+ await Promise.all(
+ snapshot.commandStacks.map(async (stackSnapshot) => {
+ if (!this.commandStacks.has(stackSnapshot.layerId)) {
+ this.commandStacks.set(
+ stackSnapshot.layerId,
+ new CommandStack(maxUndoRedoSteps),
+ );
+ }
+ await this.commandStacks.get(stackSnapshot.layerId)?.applySnapshot({
+ ...stackSnapshot.commandStack,
+ buffer: stackSnapshot.commandStack.buffer
+ .map((commandSnapshot) => {
+ const Command = commandMap[commandSnapshot.kind];
+ if (!Command) return;
+ return new Command(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ commandSnapshot as any,
+ this.document,
+ );
+ })
+ .filter((command) => Boolean(command)) as IUndoRedoCommand[],
+ });
+ }),
+ );
+ }
- this.clear();
- return Promise.resolve();
+ public hasChanges(layerId?: string): boolean {
+ if (!layerId) {
+ return [...this.commandStacks.values()].some((stack) => stack.isDirty());
+ }
+ return this.commandStacks.get(layerId)?.isDirty() ?? false;
+ }
+
+ public updateCheckpoint(layerId?: string) {
+ if (!layerId) {
+ [...this.commandStacks.values()].forEach((stack) => stack.save());
+ return;
+ }
+ this.commandStacks.get(layerId)?.save();
}
}
diff --git a/apps/editor/src/models/editor/layer-families/index.ts b/apps/editor/src/models/editor/layer-families/index.ts
new file mode 100644
index 000000000..df0b2a8fd
--- /dev/null
+++ b/apps/editor/src/models/editor/layer-families/index.ts
@@ -0,0 +1 @@
+export * from "./layer-family";
diff --git a/apps/editor/src/models/editor/layer-families/layer-family.ts b/apps/editor/src/models/editor/layer-families/layer-family.ts
new file mode 100644
index 000000000..729177707
--- /dev/null
+++ b/apps/editor/src/models/editor/layer-families/layer-family.ts
@@ -0,0 +1,117 @@
+import { ILayer, ILayerFamily, LayerFamilySnapshot } from "@visian/ui-shared";
+import {
+ BackendMetadata,
+ ISerializable,
+ isMiaAnnotationMetadata,
+} from "@visian/utils";
+import { action, computed, makeObservable, observable } from "mobx";
+import { v4 as uuidv4 } from "uuid";
+
+import { Document } from "../document";
+import { ImageLayer } from "../layers";
+
+export class LayerFamily
+ implements ILayerFamily, ISerializable
+{
+ public excludeFromSnapshotTracking = ["document"];
+ protected layerIds: string[] = [];
+ public title = "";
+ public id!: string;
+ public collapsed?: boolean;
+ public metadata?: BackendMetadata;
+
+ constructor(
+ snapshot: Partial | undefined,
+ protected document: Document,
+ ) {
+ this.applySnapshot(snapshot);
+
+ makeObservable(this, {
+ layerIds: observable,
+ collapsed: observable,
+ title: observable,
+ isActive: computed,
+ metadata: observable,
+
+ addLayer: action,
+ removeLayer: action,
+ trySetIsVerified: action,
+ });
+ }
+
+ public get layers(): ILayer[] {
+ return this.layerIds.map((id) => {
+ const layer = this.document.getLayer(id);
+ if (layer !== null && layer !== undefined) {
+ return layer;
+ }
+ throw new Error(`Layer with id ${id} not found`);
+ });
+ }
+
+ public get hasChanges() {
+ return this.layers.some(
+ (layer) => layer.kind === "image" && (layer as ImageLayer).hasChanges,
+ );
+ }
+
+ public addLayer(id: string, index?: number) {
+ const layer = this.document.getLayer(id);
+ if (!layer) return;
+ if (layer.family !== this) {
+ layer.family?.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 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 get isActive() {
+ if (this.document.activeLayer) {
+ return this.layers.includes(this.document.activeLayer);
+ }
+ return false;
+ }
+
+ public toJSON(): LayerFamilySnapshot {
+ return {
+ id: this.id,
+ title: this.title,
+ metadata: this.metadata ? { ...this.metadata } : undefined,
+ layerIds: [...this.layerIds],
+ };
+ }
+
+ public async applySnapshot(
+ snapshot: Partial | undefined,
+ ) {
+ if (!snapshot) return;
+ this.id = snapshot.id || uuidv4();
+ this.title = snapshot.title || "";
+ this.metadata = snapshot.metadata ? { ...snapshot.metadata } : undefined;
+ this.layerIds = snapshot.layerIds || [];
+ }
+
+ public trySetIsVerified(value: boolean) {
+ if (isMiaAnnotationMetadata(this.metadata)) {
+ this.metadata = {
+ ...this.metadata,
+ verified: value,
+ };
+ }
+ }
+}
diff --git a/apps/editor/src/models/editor/layers/image-layer/image-layer.ts b/apps/editor/src/models/editor/layers/image-layer/image-layer.ts
index fca2ebcb1..3758c06aa 100644
--- a/apps/editor/src/models/editor/layers/image-layer/image-layer.ts
+++ b/apps/editor/src/models/editor/layers/image-layer/image-layer.ts
@@ -3,6 +3,7 @@ import {
Histogram,
IDocument,
IImageLayer,
+ LayerSnapshot,
MarkerConfig,
} from "@visian/ui-shared";
import {
@@ -23,7 +24,7 @@ import { action, computed, makeObservable, observable } from "mobx";
import { defaultAnnotationColor } from "../../../../constants";
import { condenseValues } from "../../markers";
-import { Layer, LayerSnapshot } from "../layer";
+import { Layer } from "../layer";
import { markerRPCProvider } from "./markers";
import {
GetAreaArgs,
@@ -166,6 +167,10 @@ export class ImageLayer
return this.image.is3D;
}
+ public get hasChanges() {
+ return this.document.history.hasChanges(this.id);
+ }
+
public setImage(value: RenderedImage): void {
this.image = value;
if (this.document.performanceMode === "high") {
@@ -451,4 +456,8 @@ export class ImageLayer
this.setEmptySlices();
return this.recomputeSliceMarkers();
}
+
+ public copy() {
+ return new ImageLayer(this.toJSON(), this.document);
+ }
}
diff --git a/apps/editor/src/models/editor/layers/layer-group.ts b/apps/editor/src/models/editor/layers/layer-group.ts
index 23ddeb365..37c55c381 100644
--- a/apps/editor/src/models/editor/layers/layer-group.ts
+++ b/apps/editor/src/models/editor/layers/layer-group.ts
@@ -1,8 +1,13 @@
-import { IDocument, ILayer, ILayerGroup } from "@visian/ui-shared";
+import {
+ IDocument,
+ ILayer,
+ ILayerGroup,
+ LayerSnapshot,
+} from "@visian/ui-shared";
import { ISerializable } from "@visian/utils";
import { action, makeObservable, observable, toJS } from "mobx";
-import { Layer, LayerSnapshot } from "./layer";
+import { Layer } from "./layer";
export interface LayerGroupSnapshot extends LayerSnapshot {
layerIds: string[];
@@ -56,7 +61,6 @@ export class LayerGroup
this.document.getLayer(idOrLayer)!.setParent();
return;
}
-
this.layerIds = this.layerIds.filter((id) => id !== idOrLayer.id);
idOrLayer.setParent();
}
diff --git a/apps/editor/src/models/editor/layers/layer.ts b/apps/editor/src/models/editor/layers/layer.ts
index bf27dc0e1..aca529ad9 100644
--- a/apps/editor/src/models/editor/layers/layer.ts
+++ b/apps/editor/src/models/editor/layers/layer.ts
@@ -3,9 +3,11 @@ import {
color,
IDocument,
ILayer,
+ ILayerFamily,
+ LayerSnapshot,
MarkerConfig,
} from "@visian/ui-shared";
-import { ISerializable, ViewType } from "@visian/utils";
+import { BackendMetadata, ISerializable, ViewType } from "@visian/utils";
import { action, computed, makeObservable, observable } from "mobx";
import { Matrix4 } from "three";
import tc from "tinycolor2";
@@ -18,22 +20,6 @@ import {
} from "../../../constants";
import { LayerGroup } from "./layer-group";
-export interface LayerSnapshot {
- kind: string;
- isAnnotation: boolean;
-
- id: string;
- titleOverride?: string;
- parentId?: string;
-
- blendMode: BlendMode;
- color?: string;
- isVisible: boolean;
- opacityOverride?: number;
-
- transformation: number[];
-}
-
export class Layer implements ILayer, ISerializable {
public excludeFromSnapshotTracking = ["document"];
@@ -52,6 +38,8 @@ export class Layer implements ILayer, ISerializable {
protected opacityOverride?: number;
public transformation!: Matrix4;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public metadata?: BackendMetadata;
constructor(
snapshot: Partial | undefined,
@@ -61,36 +49,41 @@ export class Layer implements ILayer, ISerializable {
this.id = snapshot?.id || uuidv4();
if (!isCalledByChild) this.applySnapshot(snapshot);
- makeObservable(
+ makeObservable<
this,
- {
- isAnnotation: observable,
- id: observable,
- titleOverride: observable,
- parentId: observable,
- blendMode: observable,
- color: observable,
- isVisible: observable,
- opacityOverride: observable,
- transformation: observable.ref,
-
- opacity: computed,
- parent: computed,
- title: computed,
-
- setParent: action,
- setIsAnnotation: action,
- setTitle: action,
- setBlendMode: action,
- setColor: action,
- setIsVisible: action,
- setOpacity: action,
- resetSettings: action,
- setTransformation: action,
- delete: action,
- applySnapshot: action,
- },
- );
+ "titleOverride" | "parentId" | "opacityOverride" | "metadata"
+ >(this, {
+ isAnnotation: observable,
+ id: observable,
+ titleOverride: observable,
+ parentId: observable,
+ blendMode: observable,
+ color: observable,
+ isVisible: observable,
+ opacityOverride: observable,
+ transformation: observable.ref,
+ metadata: observable,
+
+ opacity: computed,
+ parent: computed,
+ title: computed,
+ family: computed,
+ isActive: computed,
+
+ setParent: action,
+ setFamily: action,
+ setIsAnnotation: action,
+ setTitle: action,
+ setBlendMode: action,
+ setColor: action,
+ setIsVisible: action,
+ setOpacity: action,
+ setMetadata: action,
+ resetSettings: action,
+ setTransformation: action,
+ delete: action,
+ applySnapshot: action,
+ });
}
public setIsAnnotation(value?: boolean): void {
@@ -108,6 +101,9 @@ export class Layer implements ILayer, ISerializable {
public setTitle = (value?: string): void => {
this.titleOverride = value;
};
+ public get isActive(): boolean {
+ return this.document.activeLayer === this;
+ }
public get parent(): ILayer | undefined {
return this.parentId ? this.document.getLayer(this.parentId) : undefined;
@@ -125,6 +121,26 @@ export class Layer implements ILayer, ISerializable {
: undefined;
}
+ public get family(): ILayerFamily | undefined {
+ return this.document.layerFamilies?.find((family) =>
+ family.layers.includes(this),
+ );
+ }
+
+ public setFamily(id?: string, index?: number): void {
+ if (!id) {
+ this.family?.removeLayer(this.id, index);
+ this.document.addLayer(this, index);
+ return;
+ }
+ const newFamily = this.document.getLayerFamily(id);
+ newFamily?.addLayer(this.id, index);
+ }
+
+ public getFamilyLayers(): ILayer[] {
+ return this.family?.layers ?? this.document.getOrphanAnnotationLayers();
+ }
+
public setBlendMode = (value?: BlendMode): void => {
this.blendMode = value || "NORMAL";
};
@@ -133,6 +149,15 @@ export class Layer implements ILayer, ISerializable {
this.color = value;
};
+ public tryToggleIsVisible = (): void => {
+ if (
+ !this.isVisible &&
+ this.document.imageLayers.length >= this.document.maxVisibleLayers
+ )
+ return;
+ this.setIsVisible(!this.isVisible);
+ };
+
public setIsVisible = (value?: boolean): void => {
this.isVisible = value ?? true;
};
@@ -157,6 +182,11 @@ export class Layer implements ILayer, ISerializable {
this.transformation = value || new Matrix4();
};
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public setMetadata = (value?: BackendMetadata): void => {
+ this.metadata = value;
+ };
+
/**
* Adjusts `this.color` if the color lookup returns an invalid color.
* Mainly used for backwards compatibility when a color is removed.
@@ -180,7 +210,10 @@ export class Layer implements ILayer, ISerializable {
public delete() {
(this.parent as LayerGroup)?.removeLayer?.(this.id);
- this.document.deleteLayer(this.id);
+ this.family?.removeLayer?.(this.id);
+ if (this.document.layers.includes(this)) {
+ this.document.deleteLayer(this.id);
+ }
}
public async toFile(): Promise {
@@ -199,7 +232,8 @@ export class Layer implements ILayer, ISerializable {
color: this.color,
isVisible: this.isVisible,
opacityOverride: this.opacityOverride,
- transformation: this.transformation.toArray(),
+ transformation: this.transformation?.toArray(),
+ metadata: this.metadata ? { ...this.metadata } : undefined,
};
}
@@ -207,7 +241,6 @@ export class Layer implements ILayer, ISerializable {
if (snapshot?.id && snapshot?.id !== this.id) {
throw new Error("Layer ids do not match");
}
-
this.setIsAnnotation(snapshot?.isAnnotation);
this.setTitle(snapshot?.titleOverride);
this.setParent(snapshot?.parentId);
@@ -220,6 +253,7 @@ export class Layer implements ILayer, ISerializable {
? new Matrix4().fromArray(snapshot.transformation)
: undefined,
);
+ this.setMetadata(snapshot?.metadata);
return Promise.resolve();
}
diff --git a/apps/editor/src/models/editor/view-settings/view-settings.ts b/apps/editor/src/models/editor/view-settings/view-settings.ts
index d10437b0e..a276b569f 100644
--- a/apps/editor/src/models/editor/view-settings/view-settings.ts
+++ b/apps/editor/src/models/editor/view-settings/view-settings.ts
@@ -84,7 +84,7 @@ export class ViewSettings
this.viewMode = value || "2D";
if (value === "3D") {
- const maxLayersIn3d = this.document.maxLayers3d;
+ const maxLayersIn3d = this.document.maxVisibleLayers3d;
if (this.document.layers.length > maxLayersIn3d) {
this.viewMode = "2D";
diff --git a/apps/editor/src/models/editor/view-settings/viewport-2d.ts b/apps/editor/src/models/editor/view-settings/viewport-2d.ts
index 382591b6b..fd98b1219 100644
--- a/apps/editor/src/models/editor/view-settings/viewport-2d.ts
+++ b/apps/editor/src/models/editor/view-settings/viewport-2d.ts
@@ -231,7 +231,6 @@ export class Viewport2D
public reset = (): void => {
this.setMainViewType();
this.setShowSideViews();
- this.setVoxelInfoMode();
this.setZoomLevel();
this.setOffset();
this.resetRotation();
diff --git a/apps/editor/src/models/review-strategy/index.ts b/apps/editor/src/models/review-strategy/index.ts
new file mode 100644
index 000000000..838861fd7
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/index.ts
@@ -0,0 +1,7 @@
+export * from "./review-task";
+export * from "./review-strategy";
+export * from "./who-review-strategy";
+export * from "./who-review-task";
+export * from "./mia-review-strategy";
+export * from "./mia-review-task";
+export * from "./review-strategy-snapshot";
diff --git a/apps/editor/src/models/review-strategy/mia-review-strategy.ts b/apps/editor/src/models/review-strategy/mia-review-strategy.ts
new file mode 100644
index 000000000..d9387c66c
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/mia-review-strategy.ts
@@ -0,0 +1,191 @@
+import { MiaAnnotationMetadata, MiaImage } from "@visian/utils";
+
+import {
+ getAnnotation,
+ getAnnotationsByJobAndImage,
+ getImagesByDataset,
+ getImagesByJob,
+ patchAnnotation,
+} from "../../queries";
+import { getImage } from "../../queries/get-image";
+import { RootStore } from "../root";
+import { MiaReviewTask } from "./mia-review-task";
+import { ReviewStrategy } from "./review-strategy";
+import {
+ MiaReviewStrategySnapshot,
+ ReviewStrategySnapshot,
+} from "./review-strategy-snapshot";
+import { TaskType } from "./review-task";
+
+export class MiaReviewStrategy extends ReviewStrategy {
+ public static fromSnapshot(
+ store: RootStore,
+ snapshot?: ReviewStrategySnapshot,
+ ) {
+ if (!snapshot) return undefined;
+ if (snapshot.backend === "mia") {
+ return new MiaReviewStrategy({
+ store,
+ images: snapshot.images ?? [],
+ jobId: snapshot.jobId,
+ allowedAnnotations: snapshot.allowedAnnotations,
+ taskType: snapshot.taskType,
+ currentReviewTask: snapshot.currentReviewTask
+ ? MiaReviewTask.fromSnapshot(snapshot.currentReviewTask)
+ : undefined,
+ });
+ }
+ return undefined;
+ }
+
+ public static async fromDataset(
+ store: RootStore,
+ datasetId: string,
+ taskType?: TaskType,
+ ) {
+ const images = await getImagesByDataset(datasetId);
+ return new MiaReviewStrategy({ store, images, taskType });
+ }
+
+ public static async fromJob(
+ store: RootStore,
+ jobId: string,
+ taskType?: TaskType,
+ ) {
+ const images = await getImagesByJob(jobId);
+ return new MiaReviewStrategy({ store, images, jobId, taskType });
+ }
+
+ public static async fromImageIds(
+ store: RootStore,
+ imageIds: string[],
+ taskType?: TaskType,
+ allowedAnnotations?: string[],
+ ) {
+ const images = await Promise.all(
+ imageIds.map(async (imageId) => getImage(imageId)),
+ );
+ return new MiaReviewStrategy({
+ store,
+ images,
+ allowedAnnotations,
+ taskType,
+ });
+ }
+
+ public static async fromAnnotationId(
+ store: RootStore,
+ annotationId: string,
+ taskType?: TaskType,
+ ) {
+ const annotation = await getAnnotation(annotationId);
+ const image = await getImage(annotation.image);
+ return new MiaReviewStrategy({
+ store,
+ images: [image],
+ allowedAnnotations: [annotationId],
+ taskType,
+ });
+ }
+
+ private images: MiaImage[];
+ private currentImageIndex: number;
+ private jobId?: string;
+ private allowedAnnotations?: Set;
+ public taskType: TaskType;
+
+ constructor({
+ store,
+ images,
+ jobId,
+ allowedAnnotations,
+ taskType,
+ currentReviewTask,
+ }: {
+ store: RootStore;
+ images: MiaImage[];
+ jobId?: string;
+ allowedAnnotations?: string[];
+ taskType?: TaskType;
+ currentReviewTask?: MiaReviewTask;
+ }) {
+ super({ store });
+ if (currentReviewTask) this.setCurrentTask(currentReviewTask);
+ this.images = images;
+ this.currentImageIndex = 0;
+ this.jobId = jobId;
+ this.allowedAnnotations = allowedAnnotations
+ ? new Set(allowedAnnotations)
+ : undefined;
+ this.taskType = taskType ?? TaskType.Review;
+ }
+
+ protected async buildTask(): Promise {
+ const currentImage = this.images[this.currentImageIndex];
+ const annotations = await getAnnotationsByJobAndImage(
+ this.jobId,
+ currentImage.id,
+ );
+
+ this.setCurrentTask(
+ new MiaReviewTask(
+ undefined,
+ this.taskType,
+ undefined,
+ currentImage,
+ this.allowedAnnotations
+ ? annotations.filter((annotation) =>
+ this.allowedAnnotations?.has(annotation.id),
+ )
+ : annotations,
+ currentImage.id,
+ ),
+ );
+ }
+
+ public async nextTask() {
+ await this.saveTask();
+ this.currentImageIndex += 1;
+ if (this.currentImageIndex >= this.images.length) {
+ await this.store?.redirectToReturnUrl({});
+ } else {
+ await this.loadTask();
+ }
+ }
+
+ public async saveTask() {
+ await this.currentTask?.save();
+ await Promise.all(
+ this.store.editor.activeDocument?.layerFamilies?.map((layerFamily) => {
+ if (
+ this.currentTask?.annotationIds.includes(
+ layerFamily.metadata?.id ?? "",
+ )
+ ) {
+ return patchAnnotation(layerFamily.metadata?.id, {
+ verified: (layerFamily.metadata as MiaAnnotationMetadata)?.verified,
+ });
+ }
+ return Promise.resolve();
+ }) ?? [],
+ );
+ }
+
+ public async importAnnotations(): Promise {
+ await this.importAnnotationsWithMetadata(true);
+ }
+ public toJSON() {
+ return {
+ backend: "mia",
+ images: this.images.map((image) => ({ ...image })),
+ jobId: this.jobId,
+ allowedAnnotations: this.allowedAnnotations
+ ? [...this.allowedAnnotations]
+ : undefined,
+ taskType: this.taskType,
+ currentReviewTask: this.currentTask
+ ? (this.currentTask as MiaReviewTask).toJSON()
+ : undefined,
+ } as MiaReviewStrategySnapshot;
+ }
+}
diff --git a/apps/editor/src/models/review-strategy/mia-review-task.ts b/apps/editor/src/models/review-strategy/mia-review-task.ts
new file mode 100644
index 000000000..54abe4687
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/mia-review-task.ts
@@ -0,0 +1,151 @@
+import {
+ FileWithMetadata,
+ MiaAnnotation,
+ MiaAnnotationMetadata,
+ MiaImage,
+ Zip,
+} from "@visian/utils";
+import { v4 as uuidv4 } from "uuid";
+
+import {
+ fetchAnnotationFile,
+ fetchImageFile,
+ patchAnnotationFile,
+ postAnnotationFile,
+} from "../../queries";
+import { ReviewTask, TaskType } from "./review-task";
+
+export interface MiaReviewTaskSnapshot {
+ kind: TaskType;
+ id: string;
+ title?: string;
+ description?: string;
+ image: MiaImage;
+ annotations: MiaAnnotation[];
+}
+
+export class MiaReviewTask extends ReviewTask {
+ public static fromSnapshot(snapshot: MiaReviewTaskSnapshot) {
+ return new MiaReviewTask(
+ snapshot.title,
+ snapshot.kind,
+ snapshot.description,
+ snapshot.image,
+ snapshot.annotations,
+ snapshot.id,
+ );
+ }
+
+ public kind: TaskType;
+ public id: string;
+ public title: string | undefined;
+ public description: string | undefined;
+
+ public get annotationIds(): string[] {
+ return [...this.annotations.keys()];
+ }
+
+ private annotations: Map;
+ public image: MiaImage;
+
+ constructor(
+ title: string | undefined,
+ kind: TaskType,
+ description: string | undefined,
+ image: MiaImage,
+ annotations: MiaAnnotation[],
+ id?: string,
+ ) {
+ super();
+ this.kind = kind;
+ this.id = id ?? uuidv4();
+ this.title = title;
+ this.description = description;
+ this.image = image;
+ this.annotations = new Map(annotations.map((a) => [a.id, a]));
+ }
+
+ public async getImageFiles() {
+ const imageMetadata = this.image;
+ const image = await fetchImageFile(this.image.id);
+ image.metadata = {
+ ...imageMetadata,
+ backend: "mia",
+ kind: "image",
+ };
+ return [await fetchImageFile(this.image.id)];
+ }
+
+ public async getAnnotationFiles(annotationId: string) {
+ const annotationMetadata = this.annotations.get(annotationId);
+ if (!annotationMetadata) {
+ throw new Error(`Annotation ${annotationId} not in Task ${this.title}.`);
+ }
+ const annotation = await fetchAnnotationFile(annotationId);
+ annotation.metadata = {
+ ...annotationMetadata,
+ backend: "mia",
+ kind: "annotation",
+ };
+ return [annotation];
+ }
+
+ public async createAnnotation(files: File[]) {
+ const file = files.length === 1 ? files[0] : await this.zipFiles(files);
+ const firstFileMeta = (files[0] as FileWithMetadata | undefined)?.metadata;
+ const dataUri =
+ (firstFileMeta as MiaAnnotationMetadata)?.dataUri ??
+ this.getAritficialAnnotationDataUri(file);
+ const newAnnotation = await postAnnotationFile(
+ this.image.id,
+ dataUri ?? this.getAritficialAnnotationDataUri(file),
+ file,
+ );
+ this.annotations.set(newAnnotation.id, newAnnotation);
+ return newAnnotation.id;
+ }
+
+ public async updateAnnotation(annotationId: string, files: File[]) {
+ const annotationMetadata = this.annotations.get(annotationId);
+ if (!annotationMetadata) {
+ throw new Error(`Annotation ${annotationId} not in Task ${this.title}.`);
+ }
+
+ const file = files.length === 1 ? files[0] : await this.zipFiles(files);
+
+ const newAnnotation = await patchAnnotationFile(annotationMetadata, file);
+ this.annotations.set(newAnnotation.id, newAnnotation);
+ }
+
+ public async save() {
+ return undefined;
+ }
+
+ private async zipFiles(files: File[]): Promise {
+ const zip = new Zip();
+ files.forEach((file, index) => {
+ if (!file) return;
+ zip.setFile(`${`00${index}`.slice(-2)}_${file.name}`, file);
+ });
+ return new File([await zip.toBlob()], `mia_zip.zip`);
+ }
+
+ private getAritficialAnnotationDataUri(file: File) {
+ return `${this.id}/${uuidv4()}.${file.name.split(".").slice(1).join(".")}`;
+ }
+
+ public getAnnotation(annotationId: string) {
+ return this.annotations.get(annotationId);
+ }
+
+ public toJSON(): MiaReviewTaskSnapshot {
+ return {
+ kind: this.kind,
+ id: this.id,
+ title: this.title,
+ description: this.description,
+ image: { ...this.image },
+ annotations: [...this.annotations.values()].map((a) => ({ ...a })),
+ };
+ }
+}
diff --git a/apps/editor/src/models/review-strategy/review-strategy-snapshot.ts b/apps/editor/src/models/review-strategy/review-strategy-snapshot.ts
new file mode 100644
index 000000000..760eb2e44
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/review-strategy-snapshot.ts
@@ -0,0 +1,23 @@
+import { MiaImage } from "@visian/utils";
+
+import { MiaReviewTaskSnapshot } from "./mia-review-task";
+import { TaskType } from "./review-task";
+import { WhoReviewTaskSnapshot } from "./who-review-task";
+
+export interface MiaReviewStrategySnapshot {
+ backend: "mia";
+ images: MiaImage[];
+ jobId?: string;
+ allowedAnnotations?: string[];
+ taskType?: TaskType;
+ currentReviewTask?: MiaReviewTaskSnapshot;
+}
+
+export interface WHOReviewStrategySnapshot {
+ backend: "who";
+ currentReviewTask?: WhoReviewTaskSnapshot;
+}
+
+export type ReviewStrategySnapshot =
+ | MiaReviewStrategySnapshot
+ | WHOReviewStrategySnapshot;
diff --git a/apps/editor/src/models/review-strategy/review-strategy.ts b/apps/editor/src/models/review-strategy/review-strategy.ts
new file mode 100644
index 000000000..565c3866d
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/review-strategy.ts
@@ -0,0 +1,89 @@
+import { action, makeObservable, observable } from "mobx";
+
+import { RootStore } from "../root";
+import { ReviewStrategySnapshot } from "./review-strategy-snapshot";
+import { ReviewTask } from "./review-task";
+
+export abstract class ReviewStrategy {
+ protected store: RootStore;
+ protected task?: ReviewTask;
+
+ constructor({ store }: { store: RootStore }) {
+ makeObservable(this, {
+ task: observable,
+ setCurrentTask: action,
+ });
+ this.store = store;
+ }
+
+ public async loadTask(): Promise {
+ if (!this.store.editor.newDocument(true)) return;
+ this.store.setProgress({ labelTx: "importing", showSplash: true });
+
+ try {
+ await this.buildTask();
+ await this.importImages();
+ await this.importAnnotations();
+ } catch {
+ this.store.setError({
+ titleTx: "import-error",
+ descriptionTx: "remote-file-error",
+ });
+ this.store.editor.setActiveDocument();
+ }
+ this.store.setProgress();
+ }
+
+ public setCurrentTask(task: ReviewTask) {
+ this.task = task;
+ }
+ public get currentTask(): ReviewTask | undefined {
+ return this.task;
+ }
+ public abstract nextTask(): Promise;
+ public abstract saveTask(): Promise;
+
+ protected abstract buildTask(): Promise;
+
+ protected abstract importAnnotations(): Promise;
+ private async importImages(): Promise {
+ const imageFiles = await this.task?.getImageFiles();
+ if (!imageFiles) throw new Error("Image files not found");
+ await this.store?.editor.activeDocument?.importFiles(
+ imageFiles,
+ undefined,
+ false,
+ );
+ }
+
+ protected async importAnnotationsWithMetadata(
+ getMetadataFromChild: boolean,
+ ): Promise {
+ if (!this.task?.annotationIds) return;
+ await Promise.all(
+ this.task?.annotationIds.map(async (annotationId, idx) => {
+ const annotationFiles = await this.task?.getAnnotationFiles(
+ annotationId,
+ );
+ if (!annotationFiles) throw new Error("Annotation files not found");
+
+ const familyFiles = this.store.editor.activeDocument?.createLayerFamily(
+ annotationFiles,
+ `Annotation ${idx + 1}`,
+ getMetadataFromChild
+ ? { ...annotationFiles[0]?.metadata }
+ : { id: annotationId, kind: "annotation", backend: "who" },
+ );
+ if (!familyFiles) throw new Error("No active Document");
+
+ await this.store?.editor.activeDocument?.importFiles(
+ familyFiles,
+ undefined,
+ true,
+ );
+ }),
+ );
+ }
+
+ public abstract toJSON(): ReviewStrategySnapshot;
+}
diff --git a/apps/editor/src/models/review-strategy/review-task.ts b/apps/editor/src/models/review-strategy/review-task.ts
new file mode 100644
index 000000000..004ef8ee6
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/review-task.ts
@@ -0,0 +1,38 @@
+import { FileWithMetadata } from "@visian/utils";
+import { AxiosResponse } from "axios";
+
+export enum TaskType {
+ Create = "create",
+ Review = "review",
+ Supervise = "supervise",
+}
+
+export abstract class ReviewTask {
+ public abstract get kind(): TaskType;
+ public abstract get id(): string;
+ public abstract get title(): string | undefined;
+ public abstract get description(): string | undefined;
+
+ // All valid Annotation Ids for the task
+ public abstract get annotationIds(): string[];
+
+ // Each task refers to one Scan, possibly composed of multiple image files
+ public abstract getImageFiles(): Promise;
+
+ // Each Annotation for a Task is possibly composed
+ public abstract getAnnotationFiles(
+ annotationId: string,
+ ): Promise;
+
+ // Creates a new annotation for the task composed of multiple files and returns the new annotation id
+ public abstract createAnnotation(files: File[]): Promise;
+
+ // Updates an existing annotation for the task by overwriting its files
+ public abstract updateAnnotation(
+ annotationId: string,
+ files: File[],
+ ): Promise;
+
+ // After calling save, we expect all changes made to the task to be saved to the backend
+ public abstract save(): Promise;
+}
diff --git a/apps/editor/src/models/review-strategy/who-review-strategy.ts b/apps/editor/src/models/review-strategy/who-review-strategy.ts
new file mode 100644
index 000000000..00ef9f476
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/who-review-strategy.ts
@@ -0,0 +1,163 @@
+import { IImageLayer, ILayerFamily } from "@visian/ui-shared";
+import {
+ FileWithMetadata,
+ getWHOTask,
+ getWHOTaskIdFromUrl,
+ setNewTaskIdForUrl,
+} from "@visian/utils";
+
+import { whoHome } from "../../constants";
+import { ImageLayer } from "../editor";
+import { RootStore } from "../root";
+import { ReviewStrategy } from "./review-strategy";
+import { ReviewStrategySnapshot } from "./review-strategy-snapshot";
+import { TaskType } from "./review-task";
+import { WHOReviewTask } from "./who-review-task";
+
+export class WHOReviewStrategy extends ReviewStrategy {
+ public static fromSnapshot(
+ store: RootStore,
+ snapshot?: ReviewStrategySnapshot,
+ ) {
+ if (!snapshot) return undefined;
+ if (snapshot.backend === "who") {
+ return new WHOReviewStrategy({
+ store,
+ currentReviewTask: snapshot.currentReviewTask
+ ? WHOReviewTask.fromSnapshot(snapshot.currentReviewTask)
+ : undefined,
+ });
+ }
+ return undefined;
+ }
+
+ constructor({
+ store,
+ currentReviewTask,
+ }: {
+ store: RootStore;
+ currentReviewTask?: WHOReviewTask;
+ }) {
+ super({ store });
+ if (currentReviewTask) this.setCurrentTask(currentReviewTask);
+ }
+
+ public async nextTask(): Promise {
+ this.store.setProgress({ labelTx: "saving", showSplash: true });
+ try {
+ await this.saveTask();
+ const response = await this.currentTask?.save();
+
+ // TODO: return to WHO Home when response code is 204
+ if (response) {
+ const newLocation = response.headers["location"];
+ if (newLocation) {
+ const urlElements = newLocation.split("/");
+ const newTaskId = urlElements[urlElements.length - 1];
+ if (newTaskId !== this.currentTask?.id) {
+ this.store?.setIsDirty(false, true);
+ setNewTaskIdForUrl(newTaskId);
+ await this.loadTask();
+ return;
+ }
+ }
+ }
+ // If no new location is given, return to the WHO page
+ window.location.href = whoHome;
+ } catch {
+ this.store?.setError({
+ titleTx: "export-error",
+ descriptionTx: "file-upload-error",
+ });
+ this.store.editor.setActiveDocument();
+ }
+ this.store.setProgress();
+ }
+
+ public async saveTask(): Promise {
+ const families = this.store.editor.activeDocument?.layerFamilies;
+ if (!families) return;
+
+ await Promise.all(
+ families.map(async (family: ILayerFamily) => {
+ const annotationId = family.metadata?.id;
+ const annotationFiles = await this.getFilesForFamily(family);
+ if (annotationFiles.length === 0) return;
+ if (annotationId) {
+ await this.currentTask?.updateAnnotation(
+ annotationId,
+ annotationFiles,
+ );
+ } else {
+ await this.currentTask?.createAnnotation(annotationFiles);
+ }
+ }),
+ );
+
+ const orphanLayers =
+ this.store.editor.activeDocument?.annotationLayers.filter(
+ (annotationLayer) => annotationLayer.family === undefined,
+ );
+ if (!orphanLayers) return;
+ const files = await this.getAnnotationFilesForLayers(orphanLayers);
+ await this.currentTask?.createAnnotation(files);
+ }
+
+ // Importing
+ protected async buildTask() {
+ const taskId = getWHOTaskIdFromUrl();
+ if (!taskId) throw new Error("No WHO task specified in URL.");
+
+ const whoTask = await getWHOTask(taskId);
+ if (!whoTask) throw new Error("WHO Task not found.");
+ this.setCurrentTask(new WHOReviewTask(whoTask));
+ }
+
+ // Saving
+ private async getFilesForFamily(family: ILayerFamily): Promise {
+ const annotationLayers = family.layers.filter(
+ (layer) => layer.kind === "image" && layer.isAnnotation,
+ ) as ImageLayer[];
+
+ return this.getAnnotationFilesForLayers(annotationLayers);
+ }
+
+ private async getAnnotationFilesForLayers(
+ layers: IImageLayer[],
+ ): Promise {
+ return Promise.all(
+ layers.map(async (layer: IImageLayer) => {
+ const layerFile =
+ await this.store?.editor.activeDocument?.getFileForLayer(layer.id);
+ if (!layerFile) {
+ throw new Error(`Could not retrieve file for layer ${layer.id}.`);
+ }
+
+ // Append metadata to file in order to store it in the correct AnnotationData object
+ if (layer.metadata) {
+ const fileWithMetadata = layerFile as FileWithMetadata;
+ fileWithMetadata.metadata = layer.metadata;
+ return fileWithMetadata;
+ }
+ return layerFile;
+ }),
+ );
+ }
+
+ protected async importAnnotations(): Promise {
+ if (this.task?.kind === TaskType.Create) {
+ this.store.editor.activeDocument?.finishBatchImport();
+ return;
+ }
+ await this.importAnnotationsWithMetadata(false);
+ }
+
+ public toJSON(): ReviewStrategySnapshot {
+ return {
+ backend: "who",
+ currentReviewTask: this.currentTask
+ ? (this.currentTask as WHOReviewTask).toJSON()
+ : undefined,
+ };
+ }
+}
diff --git a/apps/editor/src/models/review-strategy/who-review-task.ts b/apps/editor/src/models/review-strategy/who-review-task.ts
new file mode 100644
index 000000000..6eb1be4a7
--- /dev/null
+++ b/apps/editor/src/models/review-strategy/who-review-task.ts
@@ -0,0 +1,179 @@
+import {
+ createBase64StringFromFile,
+ createFileFromBase64,
+ FileWithMetadata,
+ putWHOTask,
+ WHOAnnotation,
+ WHOAnnotationData,
+ WHOAnnotationStatus,
+ WHOTask,
+ WHOTaskSnapshot,
+ WHOTaskType,
+} from "@visian/utils";
+import { AxiosResponse } from "axios";
+
+import { ReviewTask, TaskType } from "./review-task";
+
+const taskTypeMapping = {
+ [WHOTaskType.Create]: TaskType.Create,
+ [WHOTaskType.Correct]: TaskType.Review,
+ [WHOTaskType.Review]: TaskType.Supervise,
+};
+
+export interface WhoReviewTaskSnapshot {
+ whoTask: WHOTaskSnapshot;
+}
+
+export class WHOReviewTask extends ReviewTask {
+ public static fromSnapshot(snapshot: WhoReviewTaskSnapshot) {
+ return new WHOReviewTask(new WHOTask(snapshot.whoTask));
+ }
+
+ private whoTask: WHOTask;
+
+ public get id(): string {
+ return this.whoTask.taskUUID;
+ }
+
+ public get kind(): TaskType {
+ return taskTypeMapping[this.whoTask.kind];
+ }
+
+ public get title(): string {
+ return this.whoTask.annotationTasks[0].title;
+ }
+
+ public get description(): string {
+ return this.whoTask.annotationTasks[0].description;
+ }
+
+ public get annotationIds(): string[] {
+ return this.whoTask.annotations.map(
+ (annotation) => annotation.annotationUUID,
+ );
+ }
+
+ constructor(whoTask: WHOTask) {
+ super();
+ // If kind is CREATE we want to ignore all existing annotations
+ if (whoTask.kind === WHOTaskType.Create) {
+ whoTask.annotations = [];
+ }
+ this.whoTask = whoTask;
+ }
+
+ public async getImageFiles() {
+ return this.whoTask.samples.map((sample) =>
+ createFileFromBase64(sample?.title, sample?.data),
+ );
+ }
+
+ public async getAnnotationFiles(annotationId: string) {
+ const title = this.whoTask?.samples[0]?.title;
+ const whoAnnotation = this.whoTask?.annotations.find(
+ (annotation) => annotation.annotationUUID === annotationId,
+ );
+ if (!whoAnnotation) return null;
+
+ return whoAnnotation.data.map((annotationData, idx) => {
+ const file = createFileFromBase64(
+ title.replace(".nii", `_annotation_${idx}`).concat(".nii"),
+ annotationData.data,
+ ) as FileWithMetadata;
+ // The file must contain the annotationDataUUID it belongs to
+ // in order to later store the modified file back to the correct
+ // WHOAnnotationData object
+ file.metadata = {
+ backend: "who",
+ kind: "annotation",
+ id: annotationData.annotationDataUUID,
+ };
+ return file;
+ });
+ }
+
+ public async createAnnotation(files: File[]) {
+ const annotationWithoutData = {
+ // TODO: set correct status
+ status: WHOAnnotationStatus.Completed,
+ data: [],
+ annotator: this.whoTask.assignee,
+ submittedAt: new Date().toISOString(),
+ };
+ const annotation = new WHOAnnotation(annotationWithoutData);
+ await Promise.all(
+ files.map(async (file) => {
+ const base64Data = await this.getBase64DataFromFile(file);
+ this.createAnnotationData(annotation, base64Data);
+ }),
+ );
+ this.whoTask.annotations.push(annotation);
+ return annotation.annotationUUID;
+ }
+
+ public async updateAnnotation(
+ annotationId: string,
+ files: File[],
+ ): Promise {
+ const annotation = this.whoTask?.annotations.find(
+ (anno: WHOAnnotation) => anno.annotationUUID === annotationId,
+ );
+ if (!annotation)
+ throw new Error(`Annotation with id ${annotationId} does not exist.`);
+
+ await Promise.all(
+ files.map(async (file) => {
+ const base64Data = await this.getBase64DataFromFile(file);
+ if ("metadata" in file) {
+ const fileWithMetadata = file as FileWithMetadata;
+ this.updateAnnotationData(
+ fileWithMetadata.metadata.id,
+ annotation,
+ base64Data,
+ );
+ } else {
+ this.createAnnotationData(annotation, base64Data);
+ }
+ }),
+ );
+ }
+
+ public async save(): Promise {
+ return putWHOTask(this.id, JSON.stringify(this.whoTask.toJSON()));
+ }
+
+ private async getBase64DataFromFile(file: File): Promise {
+ const base64LayerData = await createBase64StringFromFile(file);
+ if (!base64LayerData || !(typeof base64LayerData === "string"))
+ throw new Error("File can not be converted to base64.");
+ return base64LayerData;
+ }
+
+ private createAnnotationData(annotation: WHOAnnotation, base64Data: string) {
+ const annotationDataForBackend = { data: base64Data };
+ annotation.data.push(new WHOAnnotationData(annotationDataForBackend));
+ }
+
+ private updateAnnotationData(
+ annotationDataUUID: string,
+ annotation: WHOAnnotation,
+ base64Data: string,
+ ) {
+ const annotationData = annotation.data.find(
+ (annoData: WHOAnnotationData) =>
+ annoData.annotationDataUUID === annotationDataUUID,
+ );
+ if (!annotationData) {
+ throw new Error(
+ `Failed to find AnnotationData with UUID ${annotationDataUUID}`,
+ );
+ }
+ annotationData.data = base64Data;
+ }
+
+ public toJSON(): WhoReviewTaskSnapshot {
+ return {
+ whoTask: this.whoTask.toJSON(),
+ };
+ }
+}
diff --git a/apps/editor/src/models/root.ts b/apps/editor/src/models/root.ts
index 1e200095e..f8164840c 100644
--- a/apps/editor/src/models/root.ts
+++ b/apps/editor/src/models/root.ts
@@ -7,24 +7,26 @@ import {
IStorageBackend,
Tab,
} from "@visian/ui-shared";
-import {
- createFileFromBase64,
- deepObserve,
- getWHOTask,
- IDisposable,
- ISerializable,
-} from "@visian/utils";
-import { action, computed, makeObservable, observable } from "mobx";
+import { deepObserve, IDisposable, ISerializable } from "@visian/utils";
+import { action, autorun, computed, makeObservable, observable } from "mobx";
+import { NavigateFunction } from "react-router-dom";
import { errorDisplayDuration } from "../constants";
import { DICOMWebServer } from "./dicomweb-server";
import { Editor, EditorSnapshot } from "./editor";
+import {
+ MiaReviewStrategy,
+ ReviewStrategy,
+ ReviewStrategySnapshot,
+ WHOReviewStrategy,
+} from "./review-strategy";
+import { Settings } from "./settings/settings";
import { Tracker } from "./tracking";
import { ProgressNotification } from "./types";
-import { Task, TaskType } from "./who";
export interface RootSnapshot {
editor: EditorSnapshot;
+ reviewStrategy?: ReviewStrategySnapshot;
}
export interface RootStoreConfig {
@@ -36,6 +38,7 @@ export class RootStore implements ISerializable, IDisposable {
public dicomWebServer?: DICOMWebServer;
public editor: Editor;
+ public settings: Settings;
/** The current theme. */
public colorMode: ColorMode = "dark";
@@ -54,7 +57,7 @@ export class RootStore implements ISerializable, IDisposable {
public refs: { [key: string]: React.RefObject } = {};
public pointerDispatch?: IDispatch;
- public currentTask?: Task;
+ public reviewStrategy?: ReviewStrategy;
public tracker?: Tracker;
@@ -64,29 +67,55 @@ export class RootStore implements ISerializable, IDisposable {
{
dicomWebServer: observable,
editor: observable,
+ settings: observable,
colorMode: observable,
error: observable,
progress: observable,
isSaved: observable,
isSaveUpToDate: observable,
refs: observable,
- currentTask: observable,
+ reviewStrategy: observable,
theme: computed,
connectToDICOMWebServer: action,
- setColorMode: action,
setError: action,
setProgress: action,
+ setColorMode: action,
applySnapshot: action,
rehydrate: action,
setIsDirty: action,
setIsSaveUpToDate: action,
setRef: action,
- setCurrentTask: action,
+ setReviewStrategy: action,
},
);
+ this.settings = new Settings();
+ this.settings.load();
+
+ autorun(() => {
+ this.colorMode = this.settings.colorMode;
+ });
+
+ autorun(() => i18n.changeLanguage(this.settings.language));
+
+ autorun(() =>
+ this.editor?.activeDocument?.setUseExclusiveSegmentations(
+ this.settings.useExclusiveSegmentations,
+ ),
+ );
+
+ autorun(() =>
+ this.editor?.activeDocument?.viewport2D.setVoxelInfoMode(
+ this.settings.voxelInfoMode,
+ ),
+ );
+
+ autorun(() =>
+ this.editor?.setPerformanceMode(this.settings.performanceMode),
+ );
+
this.editor = new Editor(undefined, {
persist: this.persist,
persistImmediately: this.persistImmediately,
@@ -95,7 +124,7 @@ export class RootStore implements ISerializable, IDisposable {
getRefs: () => this.refs,
setError: this.setError,
getTracker: () => this.tracker,
- getColorMode: () => this.colorMode,
+ getSettings: () => this.settings,
});
deepObserve(this.editor, this.persist, {
@@ -130,13 +159,6 @@ export class RootStore implements ISerializable, IDisposable {
}
}
- public setColorMode(theme: ColorMode, shouldPersist = true) {
- this.colorMode = theme;
- if (shouldPersist && this.shouldPersist) {
- localStorage.setItem("theme", theme);
- }
- }
-
public get theme() {
return getTheme(this.colorMode);
}
@@ -159,79 +181,16 @@ export class RootStore implements ISerializable, IDisposable {
this.progress = progress;
}
+ public setColorMode(colorMode: ColorMode) {
+ this.colorMode = colorMode;
+ }
+
public initializeTracker() {
if (this.tracker) return;
this.tracker = new Tracker(this.editor);
this.tracker.startSession();
}
- public async loadWHOTask(taskId: string) {
- if (!taskId) return;
-
- try {
- if (this.editor.newDocument(true)) {
- this.setProgress({ labelTx: "importing", showSplash: true });
- const taskJson = await getWHOTask(taskId);
- // We want to ignore possible other annotations if type is "CREATE"
- if (taskJson.kind === TaskType.Create) {
- taskJson.annotations = [];
- }
- const whoTask = new Task(taskJson);
- this.setCurrentTask(whoTask);
-
- await Promise.all(
- whoTask.samples.map(async (sample) => {
- await this.editor.activeDocument?.importFiles(
- createFileFromBase64(sample.title, sample.data),
- undefined,
- false,
- );
- }),
- );
- if (whoTask.kind === TaskType.Create) {
- this.editor.activeDocument?.finishBatchImport();
- this.currentTask?.addNewAnnotation();
- } else {
- // Task Type is Correct or Review
- await Promise.all(
- whoTask.annotations.map(async (annotation, index) => {
- const title =
- whoTask.samples[index].title ||
- whoTask.samples[0].title ||
- `annotation_${index}`;
-
- await Promise.all(
- annotation.data.map(async (annotationData) => {
- const createdLayerId =
- await this.editor.activeDocument?.importFiles(
- createFileFromBase64(
- title.replace(".nii", "_annotation").concat(".nii"),
- annotationData.data,
- ),
- title.replace(".nii", "_annotation"),
- true,
- );
- if (createdLayerId)
- annotationData.correspondingLayerId = createdLayerId;
- }),
- );
- }),
- );
- }
- }
- } catch {
- this.setError({
- titleTx: "import-error",
- descriptionTx: "remote-file-error",
- });
- this.editor.setActiveDocument();
- }
-
- this.setProgress();
- }
-
- // Persistence
-
/**
* Indicates if there are changes that have not yet been written by the
* given storage backend.
@@ -257,8 +216,8 @@ export class RootStore implements ISerializable, IDisposable {
}
}
- public setCurrentTask(task?: Task) {
- this.currentTask = task;
+ public setReviewStrategy(reviewStrategy: ReviewStrategy) {
+ this.reviewStrategy = reviewStrategy;
}
public persist = async () => {
@@ -269,6 +228,9 @@ export class RootStore implements ISerializable, IDisposable {
this.setIsSaveUpToDate(true);
return this.editor.toJSON();
});
+ await this.config.storageBackend?.persist("/review", () =>
+ this.reviewStrategy?.toJSON(),
+ );
this.setIsDirty(false);
};
@@ -279,15 +241,37 @@ export class RootStore implements ISerializable, IDisposable {
"/editor",
this.editor.toJSON(),
);
+ await this.config.storageBackend?.persistImmediately(
+ "/review",
+ this.reviewStrategy?.toJSON(),
+ );
this.setIsDirty(false);
};
public toJSON() {
- return { editor: this.editor.toJSON() };
+ return {
+ editor: this.editor.toJSON(),
+ reviewStrategy: this.reviewStrategy?.toJSON(),
+ };
+ }
+
+ protected async applyReviewStrategySnapshot(
+ snapshot?: ReviewStrategySnapshot,
+ ) {
+ if (!snapshot) return;
+ let reviewStrategy;
+ if (snapshot.backend === "mia") {
+ reviewStrategy = MiaReviewStrategy.fromSnapshot(this, snapshot);
+ }
+ if (snapshot.backend === "who") {
+ reviewStrategy = WHOReviewStrategy.fromSnapshot(this, snapshot);
+ }
+ if (reviewStrategy) this.setReviewStrategy(reviewStrategy);
}
public async applySnapshot(snapshot: RootSnapshot) {
await this.editor.applySnapshot(snapshot.editor);
+ await this.applyReviewStrategySnapshot(snapshot.reviewStrategy);
}
public async rehydrate() {
@@ -299,38 +283,101 @@ export class RootStore implements ISerializable, IDisposable {
this.connectToDICOMWebServer(dicomWebServer, false);
}
- const theme = localStorage.getItem("theme");
- if (theme) this.setColorMode(theme as ColorMode, false);
-
if (!tab.isMainTab) return;
const editorSnapshot = await this.config.storageBackend?.retrieve(
"/editor",
);
+ const reviewStrategySnapshot = await this.config.storageBackend?.retrieve(
+ "/review",
+ );
if (editorSnapshot) {
await this.editor.applySnapshot(editorSnapshot as EditorSnapshot);
}
+ if (reviewStrategySnapshot) {
+ await this.applyReviewStrategySnapshot(
+ reviewStrategySnapshot as ReviewStrategySnapshot,
+ );
+ }
+ this.settings.load();
this.shouldPersist = true;
}
- public destroy = async (forceDestroy?: boolean) => {
- if (!this.shouldPersist && !forceDestroy) return;
+ public destroyLayers = async (forceDestroy?: boolean): Promise => {
+ if (!this.shouldPersist && !forceDestroy) return false;
if (
!forceDestroy &&
// eslint-disable-next-line no-alert
!window.confirm(i18n.t("erase-application-data-confirmation"))
)
- return;
+ return false;
this.shouldPersist = false;
- localStorage.clear();
+
await this.config.storageBackend?.clear();
this.setIsDirty(false, true);
- window.location.href = new URL(window.location.href).searchParams.has(
- "tracking",
- )
- ? `${window.location.pathname}?tracking`
- : window.location.pathname;
+
+ return true;
+ };
+
+ public destroy = async (forceDestroy?: boolean): Promise => {
+ if (await this.destroyLayers(forceDestroy)) {
+ window.location.href = new URL(window.location.href).searchParams.has(
+ "tracking",
+ )
+ ? `${window.location.pathname}?tracking`
+ : window.location.pathname;
+ return true;
+ }
+ return false;
+ };
+
+ public destroyReload = async (forceDestroy?: boolean): Promise => {
+ if (await this.destroyLayers(forceDestroy)) {
+ const redirectURl = new URL(window.location.href);
+ window.location.href = redirectURl.href;
+ return true;
+ }
+ return false;
+ };
+
+ public destroyRedirect = async (
+ redirect: string,
+ forceDestroy?: boolean,
+ ): Promise => {
+ if (await this.destroyLayers(forceDestroy)) {
+ const redirectURl = new URL(window.location.origin + redirect);
+ window.location.href = redirectURl.href;
+ return true;
+ }
+ return false;
+ };
+
+ public startReview = async (
+ createStrategy: () => Promise,
+ navigate: NavigateFunction,
+ ) => {
+ this.editor.setReturnUrl(window.location.pathname);
+ if (!(await this.destroyLayers(true))) return;
+ this.shouldPersist = true;
+ this.setProgress({ labelTx: "importing", showSplash: true });
+ navigate("/editor?review=true");
+ this.setReviewStrategy(await createStrategy());
+ await this.reviewStrategy?.loadTask();
+ this.setProgress();
+ };
+
+ public redirectToReturnUrl = async ({
+ forceRedirect,
+ resetUrl,
+ }: {
+ destroy?: boolean;
+ forceRedirect?: boolean;
+ resetUrl?: boolean;
+ }) => {
+ const returnUrl = this.editor.returnUrl ?? "/";
+ if (resetUrl ?? false) this.editor.setReturnUrl();
+ await this.destroyRedirect(returnUrl, forceRedirect ?? true);
};
}
diff --git a/apps/editor/src/models/settings/settings.ts b/apps/editor/src/models/settings/settings.ts
new file mode 100644
index 000000000..1486da7d0
--- /dev/null
+++ b/apps/editor/src/models/settings/settings.ts
@@ -0,0 +1,112 @@
+import {
+ ColorMode,
+ PerformanceMode,
+ SupportedLanguage,
+} from "@visian/ui-shared";
+import { VoxelInfoMode } from "@visian/utils";
+import { action, makeObservable, observable } from "mobx";
+
+type SettingKey = keyof typeof defaultValues;
+
+const defaultValues: {
+ colorMode: ColorMode;
+ language: SupportedLanguage;
+ useExclusiveSegmentations: boolean;
+ voxelInfoMode: VoxelInfoMode;
+ performanceMode: PerformanceMode;
+} = {
+ colorMode: "dark",
+ language: "en",
+ useExclusiveSegmentations: false,
+ voxelInfoMode: "off",
+ performanceMode: "low",
+};
+
+export class Settings {
+ public colorMode: ColorMode = defaultValues.colorMode;
+ public language: SupportedLanguage = defaultValues.language;
+ public useExclusiveSegmentations: boolean =
+ defaultValues.useExclusiveSegmentations;
+ public voxelInfoMode: VoxelInfoMode = defaultValues.voxelInfoMode;
+ public performanceMode: PerformanceMode = defaultValues.performanceMode;
+
+ constructor() {
+ makeObservable(this, {
+ colorMode: observable,
+ language: observable,
+ useExclusiveSegmentations: observable,
+ voxelInfoMode: observable,
+ performanceMode: observable,
+
+ setColorMode: action,
+ setLanguage: action,
+ setUseExclusiveSegmentations: action,
+ setVoxelInfoMode: action,
+ setPerformanceMode: action,
+ });
+ }
+
+ public setColorMode(colorMode: ColorMode) {
+ this.colorMode = colorMode;
+ this.writeSetting("colorMode", this.colorMode);
+ }
+
+ public setLanguage(lang: SupportedLanguage) {
+ this.language = lang;
+ this.writeSetting("language", this.language);
+ }
+
+ public setUseExclusiveSegmentations(exclusiveSegmentations: boolean) {
+ this.useExclusiveSegmentations = exclusiveSegmentations;
+ this.writeSetting(
+ "useExclusiveSegmentations",
+ this.useExclusiveSegmentations ? "1" : "0",
+ );
+ }
+
+ public setVoxelInfoMode(voxelInfoMode: VoxelInfoMode) {
+ this.voxelInfoMode = voxelInfoMode;
+ this.writeSetting("voxelInfoMode", this.voxelInfoMode);
+ }
+
+ public setPerformanceMode(performanceMode: PerformanceMode) {
+ this.performanceMode = performanceMode;
+ this.writeSetting("performanceMode", this.performanceMode);
+ }
+
+ public persist() {
+ this.writeSetting("colorMode", this.colorMode);
+ this.writeSetting("language", this.language);
+ this.writeSetting(
+ "useExclusiveSegmentations",
+ this.useExclusiveSegmentations ? "1" : "0",
+ );
+ this.writeSetting("voxelInfoMode", this.voxelInfoMode);
+ this.writeSetting("performanceMode", this.performanceMode);
+ }
+
+ public load() {
+ const colorMode = this.readSetting("colorMode");
+ const language = this.readSetting("language");
+ const useExclusiveSegmentations = this.readSetting(
+ "useExclusiveSegmentations",
+ );
+ const voxelInfoMode = this.readSetting("voxelInfoMode");
+ const performanceMode = this.readSetting("performanceMode");
+
+ this.setColorMode(colorMode);
+ this.setLanguage(language);
+ this.setUseExclusiveSegmentations(useExclusiveSegmentations);
+ this.setVoxelInfoMode(voxelInfoMode);
+ this.setPerformanceMode(performanceMode);
+ }
+
+ protected writeSetting(key: T, value: string): void {
+ localStorage.setItem(`settings.${key}`, value);
+ }
+
+ protected readSetting(key: T): typeof defaultValues[T] {
+ return (localStorage.getItem(`settings.${key}`) ||
+ defaultValues[key]) as typeof defaultValues[T];
+ }
+}
diff --git a/apps/editor/src/models/types.ts b/apps/editor/src/models/types.ts
index ab4e3695a..f38f11d42 100644
--- a/apps/editor/src/models/types.ts
+++ b/apps/editor/src/models/types.ts
@@ -1,6 +1,7 @@
-import type { ColorMode, ErrorNotification, Theme } from "@visian/ui-shared";
+import type { ErrorNotification, Theme } from "@visian/ui-shared";
import type React from "react";
+import { Settings } from "./settings/settings";
import type { Tracker } from "./tracking";
export interface StoreContext {
@@ -26,7 +27,7 @@ export interface StoreContext {
getTracker(): Tracker | undefined;
- getColorMode(): ColorMode;
+ getSettings(): Settings;
}
export interface ProgressNotification {
diff --git a/apps/editor/src/models/who/task.ts b/apps/editor/src/models/who/task.ts
deleted file mode 100644
index e2f8dbbe3..000000000
--- a/apps/editor/src/models/who/task.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Annotation, AnnotationSnapshot, AnnotationStatus } from "./annotation";
-import { AnnotationTask, AnnotationTaskSnapshot } from "./annotationTask";
-import { Sample } from "./sample";
-import { User, UserSnapshot } from "./user";
-
-export interface TaskSnapshot {
- taskUUID: string;
- kind: string;
- readOnly: boolean;
- annotationTasks: AnnotationTaskSnapshot[];
- annotations?: AnnotationSnapshot[];
- assignee: UserSnapshot;
-}
-
-export enum TaskType {
- Create = "create",
- Correct = "correct",
- Review = "review",
-}
-
-export class Task {
- public taskUUID: string;
- public kind: TaskType;
- public readOnly: boolean;
- public annotationTasks: AnnotationTask[];
- public samples: Sample[];
- public annotations: Annotation[];
- public assignee: User;
-
- // TODO: Properly type API response data
- constructor(task: any) {
- this.taskUUID = task.taskUUID;
- this.kind = task.kind;
- this.readOnly = task.readOnly;
- this.annotationTasks = task.annotationTasks.map(
- (annotationTask: any) => new AnnotationTask(annotationTask),
- );
- this.samples = task.samples.map((sample: any) => new Sample(sample));
- this.annotations = task.annotations.map(
- (annotation: any) => new Annotation(annotation),
- );
- this.assignee = new User(task.assignee);
- }
-
- public addNewAnnotation(): void {
- const annotationData = {
- status: AnnotationStatus.Pending,
- data: [],
- annotator: this.assignee,
- submittedAt: "",
- };
- const annotation = new Annotation(annotationData);
- this.annotations.push(annotation);
- }
-
- public toJSON(): TaskSnapshot {
- return {
- taskUUID: this.taskUUID,
- kind: this.kind,
- readOnly: this.readOnly,
- annotationTasks: Object.values(this.annotationTasks).map(
- (annotationTask) => annotationTask.toJSON(),
- ),
- annotations: Object.values(this.annotations).map((annotation) =>
- annotation.toJSON(),
- ),
- assignee: this.assignee.toJSON(),
- };
- }
-}
diff --git a/apps/editor/src/queries/get-image.tsx b/apps/editor/src/queries/get-image.tsx
new file mode 100644
index 000000000..1742397ea
--- /dev/null
+++ b/apps/editor/src/queries/get-image.tsx
@@ -0,0 +1,11 @@
+import { MiaImage } from "@visian/utils";
+import axios from "axios";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const getImage = async (imageId: string) => {
+ const imageResponse = await axios.get(
+ `${hubBaseUrl}images/${imageId}`,
+ );
+ return imageResponse.data;
+};
diff --git a/apps/editor/src/queries/get-job-log.tsx b/apps/editor/src/queries/get-job-log.tsx
new file mode 100644
index 000000000..7aa9afb40
--- /dev/null
+++ b/apps/editor/src/queries/get-job-log.tsx
@@ -0,0 +1,8 @@
+import axios from "axios";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const getJobLog = async (jobId: string) => {
+ const response = await axios.get(`${hubBaseUrl}jobs/${jobId}/log-file`);
+ return response.data;
+};
diff --git a/apps/editor/src/queries/hub-base-url.tsx b/apps/editor/src/queries/hub-base-url.tsx
new file mode 100644
index 000000000..5fd89b700
--- /dev/null
+++ b/apps/editor/src/queries/hub-base-url.tsx
@@ -0,0 +1,20 @@
+const formatUrl = (url: string | null | undefined) => {
+ if (!url || url === "") {
+ return url;
+ }
+ let formattedUrl = url;
+ if (
+ !formattedUrl.startsWith("http://") &&
+ !formattedUrl.startsWith("https://")
+ ) {
+ formattedUrl = `http://${formattedUrl}`;
+ }
+ if (!formattedUrl.endsWith("/")) {
+ formattedUrl = `${formattedUrl}/`;
+ }
+ return formattedUrl;
+};
+
+export const hubBaseUrl = formatUrl(process.env.NX_ANNOTATION_SERVICE_HUB_URL);
+
+export default hubBaseUrl;
diff --git a/apps/editor/src/queries/index.ts b/apps/editor/src/queries/index.ts
new file mode 100644
index 000000000..e510d1ee6
--- /dev/null
+++ b/apps/editor/src/queries/index.ts
@@ -0,0 +1,16 @@
+export * from "./get-job-log";
+export * from "./use-annotations-by";
+export * from "./use-dataset";
+export * from "./use-datasets-by";
+export * from "./use-images-by-dataset";
+export * from "./use-images-by-jobs";
+export * from "./use-job";
+export * from "./use-jobs";
+export * from "./use-jobs-by";
+export * from "./use-ml-models";
+export * from "./use-project";
+export * from "./use-projects";
+export * from "./use-files";
+export * from "./post-job";
+export * from "./post-image";
+export * from "./hub-base-url";
diff --git a/apps/editor/src/queries/post-image.tsx b/apps/editor/src/queries/post-image.tsx
new file mode 100644
index 000000000..f705ab70e
--- /dev/null
+++ b/apps/editor/src/queries/post-image.tsx
@@ -0,0 +1,17 @@
+import axios from "axios";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const postImage = async (
+ datasetId: string,
+ dataUri: string,
+ imageFile: File,
+) => {
+ const formData = new FormData();
+ formData.append("dataset", datasetId);
+ formData.append("dataUri", dataUri);
+ formData.append("file", imageFile);
+ await axios.post(`${hubBaseUrl}images`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+};
diff --git a/apps/editor/src/queries/post-job.tsx b/apps/editor/src/queries/post-job.tsx
new file mode 100644
index 000000000..21a65ce7c
--- /dev/null
+++ b/apps/editor/src/queries/post-job.tsx
@@ -0,0 +1,17 @@
+import { MiaMlModel } from "@visian/utils";
+import axios from "axios";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const postJob = async (
+ imageSelection: string[],
+ selectedModel: MiaMlModel,
+ projectId: string,
+) => {
+ await axios.post(`${hubBaseUrl}jobs`, {
+ images: imageSelection,
+ modelName: selectedModel.name,
+ modelVersion: selectedModel.version,
+ project: projectId,
+ });
+};
diff --git a/apps/editor/src/queries/use-annotations-by.tsx b/apps/editor/src/queries/use-annotations-by.tsx
new file mode 100644
index 000000000..85c0dfc36
--- /dev/null
+++ b/apps/editor/src/queries/use-annotations-by.tsx
@@ -0,0 +1,170 @@
+import { MiaAnnotation } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const patchAnnotation = async (
+ annotationId?: string,
+ annotation?: Partial,
+) => {
+ const annotationsResponse = await axios.patch(
+ `${hubBaseUrl}annotations/${annotationId}`,
+ {
+ dataUri: annotation?.dataUri,
+ verified: annotation?.verified,
+ },
+ );
+ return annotationsResponse.data;
+};
+
+export const getAnnotationsByJobAndImage = async (
+ jobId?: string,
+ imageId?: string,
+) => {
+ const annotationsResponse = await axios.get(
+ `${hubBaseUrl}annotations`,
+ {
+ params: {
+ job: jobId,
+ image: imageId,
+ },
+ },
+ );
+ return annotationsResponse.data;
+};
+
+export const getAnnotation = async (annotationId: string) => {
+ const annotationsResponse = await axios.get(
+ `${hubBaseUrl}annotations/${annotationId}`,
+ );
+ return annotationsResponse.data;
+};
+
+const deleteAnnotations = async ({
+ imageId,
+ annotationIds,
+}: {
+ imageId: string;
+ annotationIds: string[];
+}) => {
+ const deleteAnnotationsResponse = await axios.delete(
+ `${hubBaseUrl}annotations`,
+ {
+ data: { ids: annotationIds },
+ timeout: 1000 * 2, // 2 secods
+ },
+ );
+
+ return deleteAnnotationsResponse.data.map((i) => i.id);
+};
+
+export const useAnnotationsByImage = (imageId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaAnnotation[],
+ AxiosError
+ >(
+ ["annotationsByImage", imageId],
+ () => getAnnotationsByJobAndImage(undefined, imageId),
+ {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 10, // refetch every 20 seconds
+ },
+ );
+
+ return {
+ annotations: data,
+ annotationsError: error,
+ isErrorAnnotations: isError,
+ isLoadingAnnotations: isLoading,
+ refetchAnnotations: refetch,
+ removeAnnotations: remove,
+ };
+};
+
+export const useAnnotationsByJob = (jobId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaAnnotation[],
+ AxiosError
+ >(["annotationsByJob", jobId], () => getAnnotationsByJobAndImage(jobId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 20, // refetch every 20 seconds
+ });
+
+ return {
+ annotations: data,
+ annotationsError: error,
+ isErrorAnnotations: isError,
+ isLoadingAnnotations: isLoading,
+ refetchAnnotations: refetch,
+ removeAnnotations: remove,
+ };
+};
+
+export const useDeleteAnnotationsForImageMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ string[],
+ AxiosError,
+ {
+ imageId: string;
+ annotationIds: string[];
+ },
+ { previousAnnotations: MiaAnnotation[] }
+ >({
+ mutationFn: deleteAnnotations,
+ onMutate: async ({
+ imageId,
+ annotationIds,
+ }: {
+ imageId: string;
+ annotationIds: string[];
+ }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["annotationsByImage", imageId],
+ });
+
+ const previousAnnotations = queryClient.getQueryData([
+ "annotationsByImage",
+ imageId,
+ ]);
+
+ if (!previousAnnotations) return;
+
+ const newAnnotations = previousAnnotations.filter(
+ (annotaion: MiaAnnotation) => !annotationIds.includes(annotaion.id),
+ );
+
+ queryClient.setQueryData(
+ ["annotationsByImage", imageId],
+ newAnnotations,
+ );
+
+ return {
+ previousAnnotations,
+ };
+ },
+ onError: (err, { imageId, annotationIds }, context) => {
+ queryClient.setQueryData(
+ ["annotationsByImage", imageId],
+ context?.previousAnnotations,
+ );
+ },
+ onSettled: (data, err, { imageId, annotationIds }) => {
+ queryClient.invalidateQueries({
+ queryKey: ["annotationsByImage", imageId],
+ });
+ },
+ });
+ return {
+ isDeleteAnnotationsError: isError,
+ isDeleteAnnotationsIdle: isIdle,
+ isDeleteAnnotationsLoading: isLoading,
+ isDeleteAnnotationsPaused: isPaused,
+ isDeleteAnnotationsSuccess: isSuccess,
+ deleteAnnotations: mutate,
+ };
+};
+
+export default useAnnotationsByImage;
diff --git a/apps/editor/src/queries/use-dataset-progress.tsx b/apps/editor/src/queries/use-dataset-progress.tsx
new file mode 100644
index 000000000..88267356e
--- /dev/null
+++ b/apps/editor/src/queries/use-dataset-progress.tsx
@@ -0,0 +1,30 @@
+import { MiaProgress } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const useDatasetProgress = (datasetId: string) => {
+ const { data, error, isLoading } = useQuery<
+ MiaProgress,
+ AxiosError
+ >(
+ ["dataset-progress", datasetId],
+ async () => {
+ const response = await axios.get(
+ `${hubBaseUrl}datasets/${datasetId}/progress`,
+ );
+ return response.data;
+ },
+ {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 5, // refetch every 5 seconds
+ },
+ );
+
+ return {
+ progress: data,
+ progressError: error,
+ isLoadingProgress: isLoading,
+ };
+};
diff --git a/apps/editor/src/queries/use-dataset.tsx b/apps/editor/src/queries/use-dataset.tsx
new file mode 100644
index 000000000..e7fa56c01
--- /dev/null
+++ b/apps/editor/src/queries/use-dataset.tsx
@@ -0,0 +1,33 @@
+import { MiaDataset } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getDataset = async (datasetId: string) => {
+ const datasetResponse = await axios.get(
+ `${hubBaseUrl}datasets/${datasetId}`,
+ );
+ return datasetResponse.data;
+};
+
+export const useDataset = (datasetId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaDataset,
+ AxiosError
+ >(["dataset", datasetId], () => getDataset(datasetId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 60, // refetch every minute
+ });
+
+ return {
+ dataset: data,
+ datasetError: error,
+ isErrorDataset: isError,
+ isLoadingDataset: isLoading,
+ refetchDataset: refetch,
+ removeDataset: remove,
+ };
+};
+
+export default useDataset;
diff --git a/apps/editor/src/queries/use-datasets-by.tsx b/apps/editor/src/queries/use-datasets-by.tsx
new file mode 100644
index 000000000..6040aeb00
--- /dev/null
+++ b/apps/editor/src/queries/use-datasets-by.tsx
@@ -0,0 +1,252 @@
+import { MiaDataset } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getDatasetsBy = async (projectId: string) => {
+ const datasetsResponse = await axios.get(
+ `${hubBaseUrl}datasets`,
+ {
+ params: {
+ project: projectId,
+ },
+ },
+ );
+ return datasetsResponse.data;
+};
+
+export const useDatasetsBy = (projectId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaDataset[],
+ AxiosError
+ >(["datasetsBy", projectId], () => getDatasetsBy(projectId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 30, // refetch every 30 seconds
+ });
+
+ return {
+ datasets: data,
+ datasetsError: error,
+ isErrorDatasets: isError,
+ isLoadingDatasets: isLoading,
+ refetchDatasets: refetch,
+ removeDatasets: remove,
+ };
+};
+
+const postDataset = async ({
+ name,
+ project,
+}: {
+ name: string;
+ project: string;
+}) => {
+ const postDatasetResponse = await axios.post(
+ `${hubBaseUrl}datasets`,
+ { name, project },
+ );
+ return postDatasetResponse.data;
+};
+
+const deleteDatasets = async ({
+ projectId,
+ datasetIds,
+}: {
+ projectId: string;
+ datasetIds: string[];
+}) => {
+ const deleteDatasetsResponse = await axios.delete(
+ `${hubBaseUrl}datasets`,
+ {
+ data: { ids: datasetIds },
+ timeout: 1000 * 2, // 2 secods
+ },
+ );
+ return deleteDatasetsResponse.data.map((d) => d.id);
+};
+
+export const useDeleteDatasetsForProjectMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ string[],
+ AxiosError,
+ {
+ projectId: string;
+ datasetIds: string[];
+ },
+ { previousDatasets: MiaDataset[] }
+ >({
+ mutationFn: deleteDatasets,
+ onMutate: async ({
+ projectId,
+ datasetIds,
+ }: {
+ projectId: string;
+ datasetIds: string[];
+ }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["datasetsBy", projectId],
+ });
+
+ const previousDatasets = queryClient.getQueryData([
+ "datasetsBy",
+ projectId,
+ ]);
+
+ if (!previousDatasets) return;
+
+ const newDatasets = previousDatasets.filter(
+ (annotaion: MiaDataset) => !datasetIds.includes(annotaion.id),
+ );
+
+ queryClient.setQueryData(["datasetsBy", projectId], newDatasets);
+
+ return {
+ previousDatasets,
+ };
+ },
+ onError: (err, { projectId, datasetIds }, context) => {
+ queryClient.setQueryData(
+ ["datasetsBy", projectId],
+ context?.previousDatasets,
+ );
+ },
+ onSettled: (data, err, { projectId, datasetIds }) => {
+ queryClient.invalidateQueries({
+ queryKey: ["datasetsBy", projectId],
+ });
+ },
+ });
+ return {
+ isDeleteDatasetsError: isError,
+ isDeleteDatasetsIdle: isIdle,
+ isDeleteDatasetsLoading: isLoading,
+ isDeleteDatasetsPaused: isPaused,
+ isDeleteDatasetsSuccess: isSuccess,
+ deleteDatasets: mutate,
+ };
+};
+
+const putDataset = async (dataset: MiaDataset) => {
+ const putDatasetResponse = await axios.put(
+ `${hubBaseUrl}datasets/${dataset.id}`,
+ {
+ name: dataset.name,
+ project: dataset.project,
+ },
+ {
+ timeout: 1000 * 3.14, // pi seconds
+ },
+ );
+ return putDatasetResponse.data;
+};
+
+export const useUpdateDatasetsMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation<
+ MiaDataset,
+ AxiosError,
+ MiaDataset,
+ { previousDatasets: MiaDataset[] }
+ >({
+ mutationFn: putDataset,
+ onMutate: async (dataset: MiaDataset) => {
+ await queryClient.cancelQueries({
+ queryKey: ["datasetsBy", dataset.project],
+ });
+
+ const previousDatasets = queryClient.getQueryData([
+ "datasetsBy",
+ dataset.project,
+ ]);
+
+ if (!previousDatasets) return;
+
+ const newDatasets = previousDatasets.map((d) =>
+ d.id === dataset.id ? dataset : d,
+ );
+
+ queryClient.setQueryData(["datasetsBy", dataset.project], newDatasets);
+
+ return {
+ previousDatasets,
+ };
+ },
+ onError: (err, dataset, context) => {
+ queryClient.setQueryData(
+ ["datasetsBy", dataset.project],
+ context?.previousDatasets,
+ );
+ },
+ onSettled: (data, err, dataset) => {
+ queryClient.invalidateQueries({
+ queryKey: ["datasetsBy", dataset.project],
+ });
+ },
+ });
+};
+
+export const useCreateDatasetMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ MiaDataset,
+ AxiosError,
+ { name: string; project: string },
+ { previousDatasets: MiaDataset[] }
+ >({
+ mutationFn: postDataset,
+ onMutate: async ({
+ name,
+ project,
+ }: {
+ name: string;
+ project: string;
+ }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["datasetsBy", project],
+ });
+
+ const previousDatasets =
+ queryClient.getQueryData(["datasetsBy", project]) ?? [];
+
+ const newDataset = {
+ id: "new-dataset",
+ name,
+ project,
+ };
+
+ queryClient.setQueryData(
+ ["datasetsBy", project],
+ [...previousDatasets, newDataset],
+ );
+
+ return {
+ previousDatasets,
+ };
+ },
+ onError: (err, { name, project }, context) => {
+ queryClient.setQueryData(
+ ["datasetsBy", project],
+ context?.previousDatasets,
+ );
+ },
+ onSettled: (data, err, { name, project }) => {
+ queryClient.invalidateQueries({
+ queryKey: ["datasetsBy", project],
+ });
+ },
+ });
+ return {
+ isCreateDatasetError: isError,
+ isCreateDatasetIdle: isIdle,
+ isCreateDatasetLoading: isLoading,
+ isCreateDatasetPaused: isPaused,
+ isCreateDatasetSuccess: isSuccess,
+ createDataset: mutate,
+ };
+};
+
+export default useDatasetsBy;
diff --git a/apps/editor/src/queries/use-files.tsx b/apps/editor/src/queries/use-files.tsx
new file mode 100644
index 000000000..155b0a8ca
--- /dev/null
+++ b/apps/editor/src/queries/use-files.tsx
@@ -0,0 +1,90 @@
+import { FileWithMetadata, MiaAnnotation } from "@visian/utils";
+import axios from "axios";
+import path from "path";
+
+import { getImage } from "./get-image";
+import { hubBaseUrl } from "./hub-base-url";
+import { getAnnotation } from "./use-annotations-by";
+
+const fetchFile = async (
+ id: string,
+ endpoint: string,
+ fileName: string,
+): Promise =>
+ fetch(`${hubBaseUrl}${endpoint}/${id}/file`, {
+ method: "GET",
+ })
+ .then((response) => response.blob())
+ .then(
+ (blob) =>
+ new File([blob], fileName, {
+ type: blob.type,
+ lastModified: Date.now(),
+ }),
+ );
+
+export const fetchImageFile = async (
+ imageId: string,
+): Promise => {
+ const image = await getImage(imageId);
+ const fileName: string = path.basename(image.dataUri);
+ const imageFile = (await fetchFile(
+ image.id,
+ "images",
+ fileName,
+ )) as FileWithMetadata;
+ imageFile.metadata = { ...image, backend: "mia", kind: "image" };
+ return imageFile;
+};
+
+export const fetchAnnotationFile = async (
+ annotationId: string,
+): Promise => {
+ const annotation = await getAnnotation(annotationId);
+ const fileName: string = path.basename(annotation.dataUri);
+ const annotationFile = (await fetchFile(
+ annotation.id,
+ "annotations",
+ fileName,
+ )) as FileWithMetadata;
+ annotationFile.metadata = {
+ ...annotation,
+ backend: "mia",
+ kind: "annotation",
+ };
+ return annotationFile;
+};
+
+export const patchAnnotationFile = async (
+ annotation: MiaAnnotation,
+ file: File,
+): Promise => {
+ const apiEndpoint = `${hubBaseUrl}annotations/${annotation.id}`;
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("dataUri", annotation.dataUri);
+ const response = await axios.patch(apiEndpoint, formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ });
+ return response.data;
+};
+
+export const postAnnotationFile = async (
+ imageId: string,
+ annotationUri: string,
+ file: File,
+): Promise => {
+ const apiEndpoint = `${hubBaseUrl}annotations`;
+ const formData = new FormData();
+ formData.append("image", imageId);
+ formData.append("dataUri", annotationUri);
+ formData.append("file", file);
+ const response = await axios.post(apiEndpoint, formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ });
+ return response.data;
+};
diff --git a/apps/editor/src/queries/use-images-by-dataset.tsx b/apps/editor/src/queries/use-images-by-dataset.tsx
new file mode 100644
index 000000000..6ad771772
--- /dev/null
+++ b/apps/editor/src/queries/use-images-by-dataset.tsx
@@ -0,0 +1,102 @@
+import { MiaImage } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const getImagesByDataset = async (datasetId?: string) => {
+ const imagesResponse = await axios.get(`${hubBaseUrl}images`, {
+ params: {
+ dataset: datasetId,
+ },
+ });
+ return imagesResponse.data;
+};
+
+const deleteImages = async (imageIds: string[]) => {
+ const deleteImagesResponse = await axios.delete(
+ `${hubBaseUrl}images`,
+ {
+ data: { ids: imageIds },
+ timeout: 1000 * 2, // 2 secods
+ },
+ );
+
+ return deleteImagesResponse.data.map((i) => i.id);
+};
+
+export const useImagesByDataset = (datasetId?: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaImage[],
+ AxiosError
+ >(["imagesByDataset", datasetId], () => getImagesByDataset(datasetId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 10, // refetch every 10 seconds
+ enabled: !!datasetId,
+ });
+
+ return {
+ images: data,
+ imagesError: error,
+ isErrorImages: isError,
+ isLoadingImages: isLoading,
+ refetchImages: refetch,
+ removeImages: remove,
+ };
+};
+
+export const useDeleteImagesMutation = (datasetId: string) => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ string[],
+ AxiosError,
+ string[],
+ { previousImages: MiaImage[] }
+ >({
+ mutationFn: deleteImages,
+ onMutate: async (imageIds: string[]) => {
+ await queryClient.cancelQueries({
+ queryKey: ["imagesByDataset", datasetId],
+ });
+
+ const previousImages = queryClient.getQueryData([
+ "imagesByDataset",
+ datasetId,
+ ]);
+
+ if (!previousImages) return;
+
+ const newImages = previousImages.filter(
+ (image: MiaImage) => !imageIds.includes(image.id),
+ );
+
+ queryClient.setQueryData(["imagesByDataset", datasetId], newImages);
+
+ return {
+ previousImages,
+ };
+ },
+ onError: (err, imagesToBeDeleted, context) => {
+ queryClient.setQueryData(
+ ["imagesByDataset", datasetId],
+ context?.previousImages,
+ );
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["imagesByDataset", datasetId],
+ });
+ },
+ });
+ return {
+ isDeleteImagesError: isError,
+ isDeleteImagesIdle: isIdle,
+ isDeleteImagesLoading: isLoading,
+ isDeleteImagesPaused: isPaused,
+ isDeleteImagesSuccess: isSuccess,
+ deleteImages: mutate,
+ };
+};
+
+export default useImagesByDataset;
diff --git a/apps/editor/src/queries/use-images-by-jobs.tsx b/apps/editor/src/queries/use-images-by-jobs.tsx
new file mode 100644
index 000000000..3f93695fd
--- /dev/null
+++ b/apps/editor/src/queries/use-images-by-jobs.tsx
@@ -0,0 +1,35 @@
+import { MiaImage } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const getImagesByJob = async (jobId: string) => {
+ const imagesResponse = await axios.get(`${hubBaseUrl}images`, {
+ params: {
+ job: jobId,
+ },
+ });
+ return imagesResponse.data;
+};
+
+export const useImagesByJob = (jobId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaImage[],
+ AxiosError
+ >(["imagesByJob", jobId], () => getImagesByJob(jobId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 10, // refetch every 10 seconds
+ });
+
+ return {
+ images: data,
+ imagesError: error,
+ isErrorImages: isError,
+ isLoadingImages: isLoading,
+ refetchImages: refetch,
+ removeImages: remove,
+ };
+};
+
+export default useImagesByJob;
diff --git a/apps/editor/src/queries/use-job-progress.tsx b/apps/editor/src/queries/use-job-progress.tsx
new file mode 100644
index 000000000..67d89d51d
--- /dev/null
+++ b/apps/editor/src/queries/use-job-progress.tsx
@@ -0,0 +1,30 @@
+import { MiaProgress } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+export const useJobProgress = (jobId: string) => {
+ const { data, error, isLoading } = useQuery<
+ MiaProgress,
+ AxiosError
+ >(
+ ["job-progress", jobId],
+ async () => {
+ const response = await axios.get(
+ `${hubBaseUrl}jobs/${jobId}/progress`,
+ );
+ return response.data;
+ },
+ {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 5, // refetch every 5 seconds
+ },
+ );
+
+ return {
+ progress: data,
+ progressError: error,
+ isLoadingProgress: isLoading,
+ };
+};
diff --git a/apps/editor/src/queries/use-job.tsx b/apps/editor/src/queries/use-job.tsx
new file mode 100644
index 000000000..396c01d3d
--- /dev/null
+++ b/apps/editor/src/queries/use-job.tsx
@@ -0,0 +1,31 @@
+import { MiaJob } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getJob = async (id: string) => {
+ const jobResponse = await axios.get(`${hubBaseUrl}jobs/${id}`);
+ return jobResponse.data;
+};
+
+export const useJob = (jobId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaJob,
+ AxiosError
+ >(["job"], () => getJob(jobId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 2, // refetch every 2 seconds
+ });
+
+ return {
+ job: data,
+ jobError: error,
+ isErrorJob: isError,
+ isLoadingJob: isLoading,
+ refetchJob: refetch,
+ removeJob: remove,
+ };
+};
+
+export default useJob;
diff --git a/apps/editor/src/queries/use-jobs-by.tsx b/apps/editor/src/queries/use-jobs-by.tsx
new file mode 100644
index 000000000..0a9cc741c
--- /dev/null
+++ b/apps/editor/src/queries/use-jobs-by.tsx
@@ -0,0 +1,177 @@
+import { MiaJob } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getJobsBy = async (projectId: string) => {
+ const jobsResponse = await axios.get(
+ `${hubBaseUrl}jobs/?project=${projectId}`,
+ );
+ return jobsResponse.data;
+};
+
+export const useJobsBy = (projectId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaJob[],
+ AxiosError
+ >(["jobs", projectId], () => getJobsBy(projectId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 20, // refetch every 20 seconds
+ });
+
+ return {
+ jobs: data,
+ jobsError: error,
+ isErrorJobs: isError,
+ isLoadingJobs: isLoading,
+ refetchJobs: refetch,
+ removeJobs: remove,
+ };
+};
+
+const patchJobStatus = async ({
+ projectId,
+ jobId,
+ jobStatus,
+}: {
+ projectId: string;
+ jobId: string;
+ jobStatus: string;
+}) => {
+ const jobsResponse = await axios.patch(
+ `${hubBaseUrl}jobs/${jobId}`,
+ { status: jobStatus },
+ {
+ timeout: 1000 * 2, // 2 seconds
+ },
+ );
+ return jobsResponse.data;
+};
+
+export const usePatchJobStatusMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ MiaJob,
+ AxiosError,
+ { projectId: string; jobId: string; jobStatus: string },
+ { previousJobs: MiaJob[] }
+ >({
+ mutationFn: patchJobStatus,
+ onMutate: async ({ projectId, jobId, jobStatus }) => {
+ await queryClient.cancelQueries({ queryKey: ["jobs", projectId] });
+
+ const previousJobs = queryClient.getQueryData([
+ "jobs",
+ projectId,
+ ]);
+
+ if (!previousJobs) return;
+
+ const updatedJobs = previousJobs.map((job) => {
+ if (job.id === jobId) {
+ return {
+ ...job,
+ status: jobStatus,
+ };
+ }
+ return job;
+ });
+
+ queryClient.setQueryData(["jobs", projectId], updatedJobs);
+
+ return { previousJobs };
+ },
+ onError: (err, { projectId }, context) => {
+ queryClient.setQueryData(["jobs", projectId], context?.previousJobs);
+ },
+ onSettled: (data, err, { projectId }) => {
+ queryClient.invalidateQueries({ queryKey: ["jobs", projectId] });
+ },
+ });
+ return {
+ isPatchJobStatusError: isError,
+ isPatchJobStatusIdle: isIdle,
+ isPatchJobStatusLoading: isLoading,
+ isPatchJobStatusPaused: isPaused,
+ isPatchJobStatusSuccess: isSuccess,
+ patchJobStatus: mutate,
+ };
+};
+
+const deleteJobs = async ({
+ projectId,
+ jobIds,
+}: {
+ projectId: string;
+ jobIds: string[];
+}) => {
+ const deleteJobsResponse = await axios.delete(`${hubBaseUrl}jobs`, {
+ data: { ids: jobIds },
+ timeout: 1000 * 2, // 2 secods
+ });
+ return deleteJobsResponse.data.map((j) => j.id);
+};
+
+export const useDeleteJobsForProjectMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ string[],
+ AxiosError,
+ {
+ projectId: string;
+ jobIds: string[];
+ },
+ { previousJobs: MiaJob[] }
+ >({
+ mutationFn: deleteJobs,
+ onMutate: async ({
+ projectId,
+ jobIds,
+ }: {
+ projectId: string;
+ jobIds: string[];
+ }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["jobs", projectId],
+ });
+
+ const previousJobs = queryClient.getQueryData([
+ "jobs",
+ projectId,
+ ]);
+
+ if (!previousJobs) return;
+
+ const newJobs = previousJobs.filter(
+ (job: MiaJob) => !jobIds.includes(job.id),
+ );
+
+ queryClient.setQueryData(["jobs", projectId], newJobs);
+
+ return {
+ previousJobs,
+ };
+ },
+ onError: (err, { projectId, jobIds }, context) => {
+ queryClient.setQueryData(["jobs", projectId], context?.previousJobs);
+ },
+ onSettled: (data, err, { projectId, jobIds }) => {
+ queryClient.invalidateQueries({
+ queryKey: ["jobs", projectId],
+ });
+ },
+ });
+ return {
+ isDeleteJobsError: isError,
+ isDeleteJobsIdle: isIdle,
+ isDeleteJobsLoading: isLoading,
+ isDeleteJobsPaused: isPaused,
+ isDeleteJobsSuccess: isSuccess,
+ deleteJobs: mutate,
+ };
+};
+
+export default useJobsBy;
diff --git a/apps/editor/src/queries/use-jobs.tsx b/apps/editor/src/queries/use-jobs.tsx
new file mode 100644
index 000000000..c7c3eb197
--- /dev/null
+++ b/apps/editor/src/queries/use-jobs.tsx
@@ -0,0 +1,31 @@
+import { MiaJob } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getJobs = async () => {
+ const jobsResponse = await axios.get(`${hubBaseUrl}jobs`);
+ return jobsResponse.data;
+};
+
+export const useJobs = () => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaJob[],
+ AxiosError
+ >(["jobs"], getJobs, {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 20, // refetch every 20 seconds
+ });
+
+ return {
+ jobs: data,
+ jobsError: error,
+ isErrorJobs: isError,
+ isLoadingJobs: isLoading,
+ refetchJobs: refetch,
+ removeJobs: remove,
+ };
+};
+
+export default useJobs;
diff --git a/apps/editor/src/queries/use-ml-models.tsx b/apps/editor/src/queries/use-ml-models.tsx
new file mode 100644
index 000000000..f8e2251bb
--- /dev/null
+++ b/apps/editor/src/queries/use-ml-models.tsx
@@ -0,0 +1,33 @@
+import { MiaMlModel } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getModelVersions = async () => {
+ const modelsResponse = await axios.get(
+ `${hubBaseUrl}model-versions`,
+ );
+ return modelsResponse.data;
+};
+
+export const useMlModels = () => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaMlModel[],
+ AxiosError
+ >(["mlModels"], getModelVersions, {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 60, // refetch every 60 seconds
+ });
+
+ return {
+ mlModels: data,
+ mlModelsError: error,
+ isErrorMlModels: isError,
+ isLoadingMlModels: isLoading,
+ refetchMlModels: refetch,
+ removeMlModels: remove,
+ };
+};
+
+export default useMlModels;
diff --git a/apps/editor/src/queries/use-project.tsx b/apps/editor/src/queries/use-project.tsx
new file mode 100644
index 000000000..30ccdcb37
--- /dev/null
+++ b/apps/editor/src/queries/use-project.tsx
@@ -0,0 +1,33 @@
+import { MiaProject } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useQuery } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getProject = async (projectId: string) => {
+ const projectResponse = await axios.get(
+ `${hubBaseUrl}projects/${projectId}`,
+ );
+ return projectResponse.data;
+};
+
+export const useProject = (projectId: string) => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaProject,
+ AxiosError
+ >(["project", projectId], () => getProject(projectId), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 60, // refetch every minute
+ });
+
+ return {
+ project: data,
+ projectError: error,
+ isErrorProject: isError,
+ isLoadingProject: isLoading,
+ refetchProject: refetch,
+ removeProject: remove,
+ };
+};
+
+export default useProject;
diff --git a/apps/editor/src/queries/use-projects.tsx b/apps/editor/src/queries/use-projects.tsx
new file mode 100644
index 000000000..d39542625
--- /dev/null
+++ b/apps/editor/src/queries/use-projects.tsx
@@ -0,0 +1,209 @@
+import { MiaProject } from "@visian/utils";
+import axios, { AxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "react-query";
+
+import { hubBaseUrl } from "./hub-base-url";
+
+const getProjects = async () => {
+ const projectsResponse = await axios.get(
+ `${hubBaseUrl}projects`,
+ );
+ return projectsResponse.data;
+};
+
+const postProject = async ({ name }: { name: string }) => {
+ const postProjectResponse = await axios.post(
+ `${hubBaseUrl}projects`,
+ { name },
+ );
+ return postProjectResponse.data;
+};
+
+export const useProjects = () => {
+ const { data, error, isError, isLoading, refetch, remove } = useQuery<
+ MiaProject[],
+ AxiosError
+ >(["project"], () => getProjects(), {
+ retry: 2, // retry twice if fetch fails
+ refetchInterval: 1000 * 60, // refetch every minute
+ });
+
+ return {
+ projects: data,
+ projectsError: error,
+ isErrorProjects: isError,
+ isLoadingProjects: isLoading,
+ refetchProjects: refetch,
+ removeProjects: remove,
+ };
+};
+
+const deleteProjects = async ({ projectIds }: { projectIds: string[] }) => {
+ const deleteProjectsResponse = await axios.delete(
+ `${hubBaseUrl}projects`,
+ {
+ data: { ids: projectIds },
+ timeout: 1000 * 2, // 2 secods
+ },
+ );
+ return deleteProjectsResponse.data.map((p) => p.id);
+};
+
+export const useDeleteProjectsMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ string[],
+ AxiosError,
+ {
+ projectIds: string[];
+ },
+ { previousProjects: MiaProject[] }
+ >({
+ mutationFn: deleteProjects,
+ onMutate: async ({ projectIds }: { projectIds: string[] }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["project"],
+ });
+
+ const previousProjects = queryClient.getQueryData([
+ "project",
+ ]);
+
+ if (!previousProjects) return;
+
+ const newProjects = previousProjects.filter(
+ (project: MiaProject) => !projectIds.includes(project.id),
+ );
+
+ queryClient.setQueryData(["project"], newProjects);
+
+ return {
+ previousProjects,
+ };
+ },
+ onError: (err, { projectIds }, context) => {
+ queryClient.setQueryData(["project"], context?.previousProjects);
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["project"],
+ });
+ },
+ });
+ return {
+ isDeleteProjectsError: isError,
+ isDeleteProjectsIdle: isIdle,
+ isDeleteProjectsLoading: isLoading,
+ isDeleteProjectsPaused: isPaused,
+ isDeleteProjectsSuccess: isSuccess,
+ deleteProjects: mutate,
+ };
+};
+
+export const useCreateProjectMutation = () => {
+ const queryClient = useQueryClient();
+ const { isError, isIdle, isLoading, isPaused, isSuccess, mutate } =
+ useMutation<
+ MiaProject,
+ AxiosError,
+ { name: string },
+ { previousProjects: MiaProject[] }
+ >({
+ mutationFn: postProject,
+ onMutate: async ({ name }: { name: string }) => {
+ await queryClient.cancelQueries({
+ queryKey: ["project"],
+ });
+
+ const previousProjects =
+ queryClient.getQueryData(["project"]) ?? [];
+
+ const newProject = {
+ id: "new-project",
+ name,
+ };
+
+ queryClient.setQueryData(
+ ["project"],
+ [...previousProjects, newProject],
+ );
+
+ return {
+ previousProjects,
+ };
+ },
+ onError: (err, { name }, context) => {
+ queryClient.setQueryData(["project"], context?.previousProjects);
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["project"],
+ });
+ },
+ });
+ return {
+ isCreateProjectError: isError,
+ isCreateProjectIdle: isIdle,
+ isCreateProjectLoading: isLoading,
+ isCreateProjectPaused: isPaused,
+ isCreateProjectSuccess: isSuccess,
+ createProject: mutate,
+ };
+};
+
+const putProject = async (project: MiaProject) => {
+ const putProjectResponse = await axios.put(
+ `${hubBaseUrl}projects/${project.id}`,
+ {
+ name: project.name,
+ },
+ {
+ timeout: 1000 * 3.14, // pi seconds
+ },
+ );
+ return putProjectResponse.data;
+};
+
+export const useUpdateProjectsMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation<
+ MiaProject,
+ AxiosError,
+ MiaProject,
+ { previousProjects: MiaProject[] }
+ >({
+ mutationFn: putProject,
+ onMutate: async (project: MiaProject) => {
+ await queryClient.cancelQueries({
+ queryKey: ["project"],
+ });
+
+ const previousProjects = queryClient.getQueryData([
+ "project",
+ ]);
+
+ if (!previousProjects) return;
+
+ const newProjects = previousProjects.map((p) =>
+ p.id === project.id ? project : p,
+ );
+
+ queryClient.setQueryData(["projects"], newProjects);
+
+ return {
+ previousProjects,
+ };
+ },
+ onError: (err, project, context) => {
+ queryClient.setQueryData(["project"], context?.previousProjects);
+ },
+ onSettled: (data, err, project) => {
+ queryClient.invalidateQueries({
+ queryKey: ["project"],
+ });
+ },
+ });
+};
+
+export default useProjects;
diff --git a/apps/editor/src/screens/dataset-screen.tsx b/apps/editor/src/screens/dataset-screen.tsx
new file mode 100644
index 000000000..047e5c42b
--- /dev/null
+++ b/apps/editor/src/screens/dataset-screen.tsx
@@ -0,0 +1,51 @@
+import { Screen, useIsDraggedOver, useTranslation } from "@visian/ui-shared";
+import { observer } from "mobx-react-lite";
+import React from "react";
+import { useParams } from "react-router-dom";
+
+import { DatasetPage, Page, PageError, PageLoadingBlock } from "../components";
+import { useDataset } from "../queries";
+
+export const DatasetScreen: React.FC = observer(() => {
+ const { t: translate } = useTranslation();
+
+ const datasetId = useParams().datasetId || "";
+ const { dataset, isErrorDataset, isLoadingDataset } = useDataset(datasetId);
+
+ const [isDraggedOver, { onDrop, ...dragListeners }] = useIsDraggedOver();
+
+ let pageContent = ;
+
+ if (isErrorDataset) {
+ pageContent = (
+
+ );
+ } else if (dataset) {
+ pageContent = (
+
+ );
+ }
+
+ return (
+
+ {pageContent}
+
+ );
+});
+
+export default DatasetScreen;
diff --git a/apps/editor/src/screens/editor-screen.tsx b/apps/editor/src/screens/editor-screen.tsx
index 4429eead4..78dcdb5fc 100644
--- a/apps/editor/src/screens/editor-screen.tsx
+++ b/apps/editor/src/screens/editor-screen.tsx
@@ -1,11 +1,22 @@
import { AbsoluteCover, Screen, useIsDraggedOver } from "@visian/ui-shared";
import { observer } from "mobx-react-lite";
-import React from "react";
+import React, { useEffect } from "react";
-import { MainView, UIOverlay } from "../components/editor";
+import { useStore } from "../app/root-store";
+import { MainView, UIOverlay } from "../components";
+import { setUpEventHandling } from "../event-handling";
export const EditorScreen: React.FC = observer(() => {
const [isDraggedOver, { onDrop, ...dragListeners }] = useIsDraggedOver();
+
+ const rootStore = useStore();
+ useEffect(() => {
+ if (!rootStore) return;
+ const [dispatch, dispose] = setUpEventHandling(rootStore);
+ rootStore.pointerDispatch = dispatch;
+ return dispose;
+ }, [rootStore]);
+
return (
diff --git a/apps/editor/src/screens/index.ts b/apps/editor/src/screens/index.ts
index 7a4d05b32..cfca952c7 100644
--- a/apps/editor/src/screens/index.ts
+++ b/apps/editor/src/screens/index.ts
@@ -1 +1,5 @@
export * from "./editor-screen";
+export * from "./dataset-screen";
+export * from "./project-screen";
+export * from "./projects-screen";
+export * from "./job-screen";
diff --git a/apps/editor/src/screens/job-screen.tsx b/apps/editor/src/screens/job-screen.tsx
new file mode 100644
index 000000000..c25bf6917
--- /dev/null
+++ b/apps/editor/src/screens/job-screen.tsx
@@ -0,0 +1,42 @@
+import { Screen, useTranslation } from "@visian/ui-shared";
+import { observer } from "mobx-react-lite";
+import React from "react";
+import { useParams } from "react-router-dom";
+
+import { JobPage, Page, PageError, PageLoadingBlock } from "../components";
+import { useJob } from "../queries";
+
+export const JobScreen: React.FC = observer(() => {
+ const { t: translate } = useTranslation();
+
+ const jobId = useParams().jobId || "";
+ const { job, isErrorJob, isLoadingJob } = useJob(jobId);
+
+ let pageContent = ;
+
+ if (isErrorJob) {
+ pageContent = (
+
+ );
+ } else if (job) {
+ pageContent = ;
+ }
+
+ return (
+
+ {pageContent}
+
+ );
+});
+
+export default JobScreen;
diff --git a/apps/editor/src/screens/project-screen.tsx b/apps/editor/src/screens/project-screen.tsx
new file mode 100644
index 000000000..79406e6c2
--- /dev/null
+++ b/apps/editor/src/screens/project-screen.tsx
@@ -0,0 +1,58 @@
+import { Screen, useTranslation } from "@visian/ui-shared";
+import { observer } from "mobx-react-lite";
+import React from "react";
+import { useParams } from "react-router-dom";
+
+import {
+ DatasetsSection,
+ JobsSection,
+ Page,
+ PageError,
+ PageLoadingBlock,
+ PageTitle,
+} from "../components";
+import { useProject } from "../queries";
+
+export const ProjectScreen: React.FC = observer(() => {
+ const projectId = useParams().projectId || "";
+ const { project, isErrorProject, isLoadingProject } = useProject(projectId);
+ const { t: translate } = useTranslation();
+
+ let pageContent = ;
+
+ if (isErrorProject) {
+ pageContent = (
+
+ );
+ } else if (project) {
+ pageContent = (
+ <>
+
+
+
+ >
+ );
+ }
+
+ return (
+
+ {pageContent}
+
+ );
+});
+
+export default ProjectScreen;
diff --git a/apps/editor/src/screens/projects-screen.tsx b/apps/editor/src/screens/projects-screen.tsx
new file mode 100644
index 000000000..ecbc51347
--- /dev/null
+++ b/apps/editor/src/screens/projects-screen.tsx
@@ -0,0 +1,204 @@
+import { Screen, useTranslation } from "@visian/ui-shared";
+import { MiaProject } from "@visian/utils";
+import { observer } from "mobx-react-lite";
+import React, { useCallback, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import styled from "styled-components";
+
+import {
+ ConfirmationPopup,
+ EditPopup,
+ GridView,
+ ListView,
+ MiaTitle,
+ PaddedPageSectionIconButton,
+ Page,
+ PageSection,
+ ProjectCreationPopup,
+} from "../components";
+import useLocalStorageToggle from "../components/data-manager/util/use-local-storage";
+import {
+ useCreateProjectMutation,
+ useDeleteProjectsMutation,
+ useProjects,
+ useUpdateProjectsMutation,
+} from "../queries";
+
+const Container = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const StyledIconButton = styled(PaddedPageSectionIconButton)`
+ padding: 0 9px;
+ height: 25px; ;
+`;
+
+export const ProjectsScreen: React.FC = observer(() => {
+ const { t: translate } = useTranslation();
+ const navigate = useNavigate();
+
+ const { projects, projectsError, isErrorProjects, isLoadingProjects } =
+ useProjects();
+ const [projectToBeDeleted, setProjectToBeDeleted] = useState();
+ const [projectToBeUpdated, setProjectToBeUpdated] = useState();
+ const { deleteProjects } = useDeleteProjectsMutation();
+ const { createProject } = useCreateProjectMutation();
+ const updateProject = useUpdateProjectsMutation();
+
+ // Delete Project Confirmation
+ const [
+ isDeleteProjectConfirmationPopUpOpen,
+ setIsDeleteProjectConfirmationPopUpOpen,
+ ] = useState(false);
+ const openDeleteProjectConfirmationPopUp = useCallback(() => {
+ setIsDeleteProjectConfirmationPopUpOpen(true);
+ }, []);
+ const closeDeleteProjectConfirmationPopUp = useCallback(() => {
+ setIsDeleteProjectConfirmationPopUpOpen(false);
+ }, []);
+
+ // Create Project
+ const [isCreateProjectPopupOpen, setIsCreateProjectPopupOpen] =
+ useState(false);
+ const openCreateProjectPopup = useCallback(
+ () => setIsCreateProjectPopupOpen(true),
+ [],
+ );
+ const closeCreateProjectPopup = useCallback(
+ () => setIsCreateProjectPopupOpen(false),
+ [],
+ );
+
+ // Delete Project
+ const deleteProject = useCallback(
+ (project: MiaProject) => {
+ setProjectToBeDeleted(project);
+ openDeleteProjectConfirmationPopUp();
+ },
+ [setProjectToBeDeleted, openDeleteProjectConfirmationPopUp],
+ );
+
+ // Open Project
+ const openProject = useCallback(
+ (project: MiaProject) => {
+ navigate(`/projects/${project.id}`);
+ },
+ [navigate],
+ );
+
+ // Edit Project
+ const [isEditPopupOpen, setIsEditPopupOpen] = useState(false);
+ const openEditPopup = useCallback(() => setIsEditPopupOpen(true), []);
+ const closeEditPopup = useCallback(() => setIsEditPopupOpen(false), []);
+
+ const editProject = useCallback(
+ (project: MiaProject) => {
+ setProjectToBeUpdated(project);
+ openEditPopup();
+ },
+ [setProjectToBeUpdated, openEditPopup],
+ );
+
+ const confirmDeleteProject = useCallback(() => {
+ if (projectToBeDeleted)
+ deleteProjects({
+ projectIds: [projectToBeDeleted.id],
+ });
+ }, [deleteProjects, projectToBeDeleted]);
+
+ // Switch between List and Grid View
+ const [isGridView, setIsGridView] = useLocalStorageToggle(
+ "isGridViewProjects",
+ true,
+ );
+ const toggleGridView = useCallback(() => {
+ setIsGridView((prev: boolean) => !prev);
+ }, [setIsGridView]);
+
+ let projectsInfoTx;
+ if (projectsError) projectsInfoTx = "projects-loading-failed";
+ else if (projects && projects.length === 0)
+ projectsInfoTx = "no-projects-available";
+
+ return (
+
+
+
+
+
+
+
+ }
+ >
+ {projects &&
+ (isGridView ? (
+
+ ) : (
+
+ ))}
+
+
+ {projectToBeUpdated && (
+
+ updateProject.mutate({ ...projectToBeUpdated, name: newName })
+ }
+ />
+ )}
+
+
+
+ );
+});
+
+export default ProjectsScreen;
diff --git a/libs/rendering/src/lib/volume-renderer/utils/axes-convention.ts b/libs/rendering/src/lib/volume-renderer/utils/axes-convention.ts
index e0a6f9992..b66b70c25 100644
--- a/libs/rendering/src/lib/volume-renderer/utils/axes-convention.ts
+++ b/libs/rendering/src/lib/volume-renderer/utils/axes-convention.ts
@@ -47,7 +47,7 @@ export class AxesConvention extends THREE.Scene implements IDisposable {
this.labels = directions.map((direction, index) => {
const labelDiv = document.createElement("div");
labelDiv.textContent = labels[index];
- labelDiv.style.fontFamily = "DIN2014";
+ labelDiv.style.fontFamily = "DINPRO";
labelDiv.style.fontSize = "13px";
labelDiv.style.fontWeight = "500";
if (isWindows()) labelDiv.style.marginLeft = "-0.07em";
diff --git a/libs/ui-shared/src/lib/components/button/button.props.ts b/libs/ui-shared/src/lib/components/button/button.props.ts
index 7d573cb2a..9e2a39b30 100644
--- a/libs/ui-shared/src/lib/components/button/button.props.ts
+++ b/libs/ui-shared/src/lib/components/button/button.props.ts
@@ -19,3 +19,9 @@ export interface ButtonProps
isActive?: boolean;
isDisabled?: boolean;
}
+export interface TimerButtonProps extends ButtonProps {
+ secondIcon: IconType;
+ timeout?: number;
+ secondTooltip?: string;
+ secondTooltipTx?: string;
+}
diff --git a/libs/ui-shared/src/lib/components/button/button.tsx b/libs/ui-shared/src/lib/components/button/button.tsx
index ec334d1a5..0ae95c669 100644
--- a/libs/ui-shared/src/lib/components/button/button.tsx
+++ b/libs/ui-shared/src/lib/components/button/button.tsx
@@ -7,7 +7,7 @@ import { sheetMixin } from "../sheet";
import { Text } from "../text";
import { Tooltip } from "../tooltip";
import { useDelay, useMultiRef } from "../utils";
-import { ButtonProps } from "./button.props";
+import { ButtonProps, TimerButtonProps } from "./button.props";
const StyledText = styled(Text)>`
font-weight: ${fontWeight("regular")};
@@ -187,4 +187,59 @@ export const InvisibleButton = styled(BaseButton)`
user-select: none;
`;
+export const TimerButton = ({
+ timeout,
+ icon,
+ secondIcon,
+ tooltip,
+ tooltipTx,
+ secondTooltip,
+ secondTooltipTx,
+ onClick,
+ ...rest
+}: TimerButtonProps) => {
+ const [isActive, setIsActive] = useState(false);
+ const [currentTimeout, setCurrentTimeout] = useState<
+ NodeJS.Timeout | undefined
+ >(undefined);
+ const defaultTimeout = 2000;
+
+ const handleClick = useCallback(
+ (event) => {
+ onClick?.(event);
+ setIsActive(true);
+ if (currentTimeout) {
+ clearTimeout(currentTimeout);
+ }
+ setCurrentTimeout(
+ setTimeout(() => {
+ setIsActive(false);
+ }, timeout ?? defaultTimeout),
+ );
+ },
+ [onClick, timeout, currentTimeout],
+ );
+ return !isActive ? (
+
+ ) : (
+
+ );
+};
+
export default Button;
diff --git a/libs/ui-shared/src/lib/components/drop-down/drop-down-options.tsx b/libs/ui-shared/src/lib/components/drop-down/drop-down-options.tsx
index 3d815d00f..c488825c0 100644
--- a/libs/ui-shared/src/lib/components/drop-down/drop-down-options.tsx
+++ b/libs/ui-shared/src/lib/components/drop-down/drop-down-options.tsx
@@ -2,7 +2,7 @@ import React, { useRef } from "react";
import ReactDOM from "react-dom";
import styled, { css } from "styled-components";
-import { fontSize, zIndex } from "../../theme";
+import { fontSize, size as getSize, radius, zIndex } from "../../theme";
import { useModalRoot } from "../box";
import { Icon } from "../icon";
import { Divider } from "../modal";
@@ -12,13 +12,18 @@ import { useOutsidePress } from "../utils";
import { DropDownOptionsProps } from "./drop-down.props";
import { useOptionsPosition } from "./utils";
-export const Option = styled.div<{ isSelected?: boolean }>`
+export const Option = styled.div<{
+ isSelected?: boolean;
+ size?: "small" | "medium";
+ borderRadius?: "default" | "round";
+}>`
align-items: center;
border: 1px solid transparent;
box-sizing: border-box;
cursor: pointer;
display: flex;
- height: 24px;
+ height: ${(props) =>
+ props.size === "medium" ? getSize("listElementHeight") : "24px"};
overflow: hidden;
user-select: none;
@@ -26,23 +31,23 @@ export const Option = styled.div<{ isSelected?: boolean }>`
props.isSelected &&
css`
${sheetMixin}
- border-radius: 12px;
- // TODO: This displays a border of twice the thickness when the last
- // option is selected. We should figure out a workaround to also use -1px
- // margin in the vertical direction and still not have the options shift
- // around when selecting a different one.
- margin: 0 -1px;
- padding: 1px;
+ border-radius: ${props.borderRadius === "default"
+ ? radius("default")
+ : props.size === "medium"
+ ? "19px"
+ : "11px"};
+ margin: 1px 1px;
`}
`;
const ExpandedSelector = styled(Option)`
- margin: -1px -1px 6px -1px;
+ margin: 1px 1px 6px 1px;
`;
-export const OptionText = styled(Text)`
+export const OptionText = styled(Text)<{ size?: "small" | "medium" }>`
flex: 1 0;
- font-size: ${fontSize("small")};
+ font-size: ${(props) =>
+ props.size === "medium" ? fontSize("default") : fontSize("small")};
margin: 0 14px;
overflow: hidden;
text-overflow: ellipsis;
@@ -60,19 +65,29 @@ const OptionDivider = styled(Divider)<{ isHidden?: boolean }>`
`}
`;
-export const ExpandIcon = styled(Icon)`
- height: 16px;
+export const ExpandIcon = styled(Icon)<{ size?: "small" | "medium" }>`
+ height: ${(props) => (props.size === "medium" ? "32px" : "16px")};
margin-right: 10px;
- width: 16px;
+ width: ${(props) => (props.size === "medium" ? "32px" : "16px")};
`;
-const Options = styled.div`
+const Options = styled("div")<{
+ size?: "small" | "medium";
+ borderRadius?: "default" | "round";
+}>`
${sheetMixin}
- border-radius: 12px;
- display: flex;
+ border-radius: ${(props) =>
+ props.borderRadius === "default"
+ ? radius("default")
+ : props.size === "medium"
+ ? "20px"
+ : "12px"};
+ display: block;
flex-direction: column;
pointer-events: auto;
- z-index: ${zIndex("picker")};
+ z-index: ${zIndex("overlayComponent")};
+ overflow-y: auto;
+ max-height: 40%;
`;
export const DropDownOptions: React.FC = ({
@@ -83,6 +98,8 @@ export const DropDownOptions: React.FC = ({
style,
onChange,
onDismiss,
+ size,
+ borderRadius,
...rest
}) => {
const ref = useRef(null);
@@ -100,25 +117,39 @@ export const DropDownOptions: React.FC = ({
const activeOption = options[activeIndex];
const node =
isOpen === false ? null : (
-
-
+
+
{activeOption && (
)}
-
+
{options.map((option, index) => (
{index < options.length - 1 && (
diff --git a/libs/ui-shared/src/lib/components/drop-down/drop-down.props.ts b/libs/ui-shared/src/lib/components/drop-down/drop-down.props.ts
index 73657f96a..42edef0b9 100644
--- a/libs/ui-shared/src/lib/components/drop-down/drop-down.props.ts
+++ b/libs/ui-shared/src/lib/components/drop-down/drop-down.props.ts
@@ -19,6 +19,9 @@ export interface DropDownOptionsProps
activeIndex?: number;
options: IEnumParameterOption[];
+ size?: "small" | "medium";
+ borderRadius?: "default" | "round";
+
/** If set to `false`, hides the modal. */
isOpen?: boolean;
onChange?: (value: T) => void;
@@ -43,6 +46,10 @@ export interface DropDownProps
options: IEnumParameterOption[];
+ size?: "small" | "medium";
+ borderRadius?: "default" | "round";
+ isDisableMixin?: boolean;
+
defaultValue?: T;
value?: T;
onChange?: (value: T) => void;
diff --git a/libs/ui-shared/src/lib/components/drop-down/drop-down.tsx b/libs/ui-shared/src/lib/components/drop-down/drop-down.tsx
index db0d1e070..b55c3ea96 100644
--- a/libs/ui-shared/src/lib/components/drop-down/drop-down.tsx
+++ b/libs/ui-shared/src/lib/components/drop-down/drop-down.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from "react";
-import styled from "styled-components";
+import styled, { css } from "styled-components";
+import { color, radius } from "../../theme";
import { FlexRow } from "../box";
import { InfoText } from "../info-text";
import { sheetMixin } from "../sheet";
@@ -13,9 +14,23 @@ import {
} from "./drop-down-options";
import { DropDownProps } from "./drop-down.props";
-const Selector = styled(Option)`
- ${sheetMixin}
- border-radius: 12px;
+const Selector = styled(Option)<{
+ size?: "small" | "medium";
+ borderRadius?: "default" | "round";
+ isDisableMixin?: boolean;
+}>`
+ ${(props) =>
+ props.isDisableMixin
+ ? css`
+ border: 1px solid ${color("sheetBorder")};
+ `
+ : sheetMixin}
+ border-radius: ${(props) =>
+ props.borderRadius === "default"
+ ? radius("default")
+ : props.size === "medium"
+ ? "20px"
+ : "12px"};
position: relative;
margin-bottom: 10px;
width: 100%;
@@ -41,6 +56,9 @@ export const DropDown: React.FC = ({
infoShortcuts,
infoPosition,
infoBaseZIndex,
+ size,
+ borderRadius,
+ isDisableMixin,
...rest
}) => {
const actualValue =
@@ -92,14 +110,18 @@ export const DropDown: React.FC = ({
{...rest}
ref={setParentRef}
onPointerDown={showOptions ? undefined : openOptions}
+ size={size}
+ borderRadius={borderRadius}
+ isDisableMixin={isDisableMixin}
>
{activeOption && (
)}
-
+
= ({
isOpen={showOptions}
onChange={setValue}
onDismiss={closeOptions}
+ size={size}
+ borderRadius={borderRadius}
/>
>
diff --git a/libs/ui-shared/src/lib/components/grid/grid.props.ts b/libs/ui-shared/src/lib/components/grid/grid.props.ts
new file mode 100644
index 000000000..3989115a7
--- /dev/null
+++ b/libs/ui-shared/src/lib/components/grid/grid.props.ts
@@ -0,0 +1,50 @@
+import type React from "react";
+
+import type { IconType } from "../icon";
+
+export interface GridItemProps extends React.HTMLAttributes {
+ labelTx?: string;
+ label?: string;
+
+ /** An optional value to pass to the item's listeners. */
+ value?: string;
+
+ /**
+ * If set to `true`, the grid item is displayed in a highlighted active
+ * state.
+ */
+ isActive?: boolean;
+
+ isLabelEditable?: boolean;
+ onChangeLabelText?: (string: string) => void;
+ onConfirmLabelText?: (value: string) => void;
+
+ /**
+ * The key of the grid item's icon (if any).
+ * Alternatively, an object with a color can be passed to render a palette
+ * element.
+ */
+ icon?: IconType | { color: string; icon?: IconType };
+ iconRef?: React.ForwardedRef;
+
+ /** If set to `true`, displays the item's icon in an disabled state. */
+ disableIcon?: boolean;
+
+ /** An optional listener that is called when the icon is pressed. */
+ onIconPress?: (value: string | undefined, event: React.PointerEvent) => void;
+
+ /** The key of the grid item's trailing icon (if any). */
+ trailingIcon?: IconType;
+ trailingIconRef?: React.ForwardedRef;
+
+ innerHeight?: string;
+
+ /** If set to `true`, displays the item's icon in an disabled state. */
+ disableTrailingIcon?: boolean;
+
+ /** An optional listener that is called when the icon is pressed. */
+ onTrailingIconPress?: (
+ value: string | undefined,
+ event: React.PointerEvent,
+ ) => void;
+}
diff --git a/libs/ui-shared/src/lib/components/grid/grid.stories.tsx b/libs/ui-shared/src/lib/components/grid/grid.stories.tsx
new file mode 100644
index 000000000..5525415b8
--- /dev/null
+++ b/libs/ui-shared/src/lib/components/grid/grid.stories.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+import { Grid, GridItem } from "./grid";
+
+export default {
+ component: Grid,
+ title: "Grid",
+};
+
+export const primary = () => (
+
+
+
+
+
+
+
+);
diff --git a/libs/ui-shared/src/lib/components/grid/grid.tsx b/libs/ui-shared/src/lib/components/grid/grid.tsx
new file mode 100644
index 000000000..0b9850bab
--- /dev/null
+++ b/libs/ui-shared/src/lib/components/grid/grid.tsx
@@ -0,0 +1,200 @@
+import React, { useCallback, useEffect, useRef } from "react";
+import styled, { css } from "styled-components";
+
+import { color, radius, size } from "../../theme";
+import { Color } from "../color";
+import { Icon } from "../icon";
+import { sheetMixin } from "../sheet";
+import { Text } from "../text";
+import { TextInput } from "../text-input";
+import { useOutsidePress } from "../utils";
+import { GridItemProps } from "./grid.props";
+
+export const Grid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ width: 100%;
+ height: 100%;
+ align-content: flex-start;
+`;
+
+const GridItemContainer = styled.div`
+ ${sheetMixin}
+
+ display: flex;
+ flex-direction: column;
+ height: 230px;
+ background-color: ${color("sheet")};
+ border-radius: ${radius("default")};
+ cursor: pointer;
+`;
+
+const GridItemInner = styled.div<
+ Pick
+>`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ overflow: hidden;
+ ${(props) =>
+ css`
+ height: ${props.innerHeight ?? size("listElementHeight")};
+ `}
+
+ ${(props) =>
+ props.isActive &&
+ css`
+ ${sheetMixin};
+ border-radius: ${radius("activeLayerBorderRadius")};
+ margin: 0 -${radius("activeLayerBorderRadius")};
+ // Accounting for the 1px border that was added
+ padding: 0 7px;
+ `}
+`;
+
+export const GridItemLabel = styled(Text)`
+ display: block;
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ user-select: none;
+`;
+
+export const GridItemInput = styled(TextInput)`
+ display: block;
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+`;
+
+export const GridIcon = styled(Icon).withConfig({
+ shouldForwardProp: (prop) =>
+ prop.toString() !== "isDisabled" &&
+ prop.toString() !== "isTrailing" &&
+ prop.toString() !== "hasPressHandler",
+})<{
+ isDisabled?: boolean;
+ isTrailing?: boolean;
+ hasPressHandler: boolean;
+}>`
+ width: 20px;
+ height: 20px;
+ ${(props) =>
+ props.isTrailing
+ ? css`
+ margin-left: 10px;
+ `
+ : css`
+ margin-right: 10px;
+ `}
+ opacity: ${(props) => (props.isDisabled ? 0.3 : 1)};
+ ${(props) =>
+ props.hasPressHandler &&
+ css`
+ cursor: pointer;
+ `}
+`;
+
+export const GridItem = React.forwardRef(
+ (
+ {
+ labelTx,
+ label,
+ icon,
+ iconRef,
+ trailingIcon,
+ trailingIconRef,
+ innerHeight,
+ value,
+ isActive,
+ isLabelEditable = false,
+ onChangeLabelText,
+ onConfirmLabelText,
+ onIconPress,
+ onTrailingIconPress,
+ disableIcon,
+ disableTrailingIcon,
+ children,
+ ...rest
+ },
+ ref,
+ ) => {
+ const handleIconPress = useCallback(
+ (event: React.PointerEvent) => {
+ if (onIconPress) onIconPress(value, event);
+ },
+ [onIconPress, value],
+ );
+ const handleTrailingIconPress = useCallback(
+ (event: React.PointerEvent) => {
+ if (onTrailingIconPress) onTrailingIconPress(value, event);
+ },
+ [onTrailingIconPress, value],
+ );
+
+ const labelInputRef = useRef(null);
+ const onOutsidePress = useCallback(() => {
+ labelInputRef.current?.blur();
+ }, []);
+ useOutsidePress(labelInputRef, onOutsidePress, isLabelEditable);
+
+ useEffect(() => {
+ if (isLabelEditable && labelInputRef.current !== document.activeElement) {
+ labelInputRef.current?.focus();
+ }
+ });
+
+ return (
+
+
+ {icon &&
+ (typeof icon === "string" ? (
+