From aa959cd62512f5916c0d4cd321bfd8ac2cde35e9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 22 Oct 2020 15:31:47 +0200 Subject: [PATCH] New Toolbar (#4875) * implement some small performance improvements * implement further small perf improvements * fix linting * avoid Three.Vector3 creation, vector unpacking etc. for position updates * avoid some THREE.Vector3 instantiations and redundant set of scale * fix linting * only set position in updateCameraForScene * use nanoEvents for buckets instead of backbone events * avoid array allocation in TextureBucketManager * fix test * rework toolbar: use icons for tools; show current cell id in navbar etc. * reflect modifier change directly in toolbar * temporarily disable most CI checks * work on dynamic overwrite-mode in UI * refactor overwrite-cell mechanism * fix tests * fix test and restore CI * fix broken merge and some padding * fix useEffect toggle bug; also adapt active tool when pressing Alt * improve tooltips for tools and fix CSS issues with borders on hover * move hooks into own module * also show tooltips when pressing modifiers in move-tool; fix missing keyRelease-bug when leaving browser tab * fix that ctrl+right-click always re-centered the current node * remove unused idle enum * swap brush and trace-tool; make lasso.svg blue via css-filtering when being active * support dragging the plane via middle-click * update changelog * only re-render SaveButton if necessary (by explicitly passing progressFraction and by accessing the oldest unsaved timestamp ad-hoc) * fix cycling order of tools; update changelog * Apply suggestions from code review Co-authored-by: Daniel * remove duplicate from changelog * allow middle-drag-movement in all tracing modes; add more comments * fix border on hovering disabled radio button * respect mapping when rendering the current cell color in the new-cell-color-button * workaround modifier-release-bug and adapt merge-tree-message * satisfy linter Co-authored-by: Daniel --- CHANGELOG.unreleased.md | 3 + docs/volume_annotation.md | 1 + frontend/javascripts/libs/input.js | 9 +- frontend/javascripts/libs/react_hooks.js | 87 +++++ frontend/javascripts/oxalis/constants.js | 17 +- .../skeletontracing_plane_controller.js | 21 +- .../volumetracing_plane_controller.js | 39 +- .../controller/viewmodes/plane_controller.js | 21 +- .../oxalis/geometries/contourgeometry.js | 5 +- .../model/actions/skeletontracing_actions.js | 7 +- .../model/actions/volumetracing_actions.js | 8 + .../model/reducers/skeletontracing_reducer.js | 8 +- .../model/reducers/volumetracing_reducer.js | 10 +- .../reducers/volumetracing_reducer_helpers.js | 18 +- .../oxalis/model/sagas/volumetracing_saga.js | 48 ++- .../oxalis/shaders/segmentation.glsl.js | 10 +- frontend/javascripts/oxalis/store.js | 4 + .../oxalis/view/action-bar/save_button.js | 47 +-- .../view/action-bar/volume_actions_view.js | 355 ++++++++++++++---- .../view/right-menu/mapping_info_view.js | 8 +- .../reducers/volumetracing_reducer.spec.js | 17 +- .../test/sagas/volumetracing_saga.spec.js | 33 +- .../stylesheets/trace_view/_tracing_view.less | 22 +- public/images/lasso.svg | 21 ++ public/images/new-cell.svg | 14 + public/images/overwrite-all.svg | 10 + public/images/overwrite-empty.svg | 10 + 27 files changed, 661 insertions(+), 192 deletions(-) create mode 100644 frontend/javascripts/libs/react_hooks.js create mode 100644 public/images/lasso.svg create mode 100644 public/images/new-cell.svg create mode 100644 public/images/overwrite-all.svg create mode 100644 public/images/overwrite-empty.svg diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 12cbf51dedb..7de7b8b6d46 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Hybrid tracings can now be imported directly in the tracing view via drag'n'drop. [#4837](https://github.com/scalableminds/webknossos/pull/4837) - The find data function now works for volume tracings, too. [#4847](https://github.com/scalableminds/webknossos/pull/4847) - Added admins and dataset managers to dataset access list, as they can access all datasets of the organization. [#4862](https://github.com/scalableminds/webknossos/pull/4862) +- Added the possibility to move the current position by dragging with the middle-mouse-button (regardless of the active tool). [#4875](https://github.com/scalableminds/webknossos/pull/4875) - Sped up the NML parsing via dashboard import. [#4872](https://github.com/scalableminds/webknossos/pull/4872) - Movements in the 3D viewport are now time-tracked. [#4876](https://github.com/scalableminds/webknossos/pull/4876) @@ -23,6 +24,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - webknossos.org only: Accounts associated with new organizations can now be created even when a datastore is unreachable. The necessary folders are created lazily when needed. [#4846](https://github.com/scalableminds/webknossos/pull/4846) - When downloading a volume tracing, buckets containing a single 0 byte, that were created to restore older versions, are now skipped. [#4851](https://github.com/scalableminds/webknossos/pull/4851) - Task information CSV now contains additional column `creationInfo`, containing the original NML filename for tasks based on existing NMLs. [#4866](https://github.com/scalableminds/webknossos/pull/4866) +- Improved the toolbar to make the different webKnossos tools easier to use. For example, the fill-tool and the cell-picker have a dedicated button in volume annotations now. [#4875](https://github.com/scalableminds/webknossos/pull/4875) +- The default overwrite-behavior in volume annotating changed so that erasing with the brush- or trace-tool always erases all voxels (regardless of their segment id). Before that, only the current segment id was overwritten by default. As before, this behavior can be toggled by pressing CTRL. Alternatively, one can now also switch the mode in the toolbar. [#4875](https://github.com/scalableminds/webknossos/pull/4875) - The dataset upload is now more robust and can recover from a failed upload of a data chunk. [#4860](https://github.com/scalableminds/webknossos/pull/4860) ### Fixed diff --git a/docs/volume_annotation.md b/docs/volume_annotation.md index db8e1ec4a99..26263769240 100644 --- a/docs/volume_annotation.md +++ b/docs/volume_annotation.md @@ -11,6 +11,7 @@ Select one of the drawing tools from the toolbar or toggle through with the keyb - `Move`: Navigate around the dataset. - `Trace`: Draw outlines around the voxel you would like to label. - `Brush`: Draw over the voxels you would like to label. Adjust the brush size with *SHIFT + Mousewheel*. +- `Fill Tool`: Flood-fill the clicked region. All adjacent voxels with the same voxel id as the clicked voxel will be changed to the active cell id. Add labels with *Left Mouse Drag*. Remove labels with *Right Mouse Drag*. diff --git a/frontend/javascripts/libs/input.js b/frontend/javascripts/libs/input.js index f24f7444836..c4f12dfa074 100644 --- a/frontend/javascripts/libs/input.js +++ b/frontend/javascripts/libs/input.js @@ -38,8 +38,8 @@ type KeyboardLoopHandler = { type KeyboardBindingPress = [KeyboardKey, KeyboardHandler, KeyboardHandler]; type KeyboardBindingDownUp = [KeyboardKey, KeyboardHandler, KeyboardHandler]; type BindingMap = { [key: KeyboardKey]: T }; -type MouseButtonWhich = 1 | 3; -type MouseButtonString = "left" | "right"; +type MouseButtonWhich = 1 | 2 | 3; +type MouseButtonString = "left" | "middle" | "right"; type MouseHandler = | ((deltaY: number, modifier: ?ModifierKeys) => void) | ((position: Point2, id: ?string, event: MouseEvent) => void) @@ -308,6 +308,7 @@ export class InputMouse { id: ?string; leftMouseButton: InputMouseButton; + middleMouseButton: InputMouseButton; rightMouseButton: InputMouseButton; isMouseOver: boolean = false; lastPosition: ?Point2 = null; @@ -338,6 +339,7 @@ export class InputMouse { this.id = id; this.leftMouseButton = new InputMouseButton("left", 1, this, this.id); + this.middleMouseButton = new InputMouseButton("middle", 2, this, this.id); this.rightMouseButton = new InputMouseButton("right", 3, this, this.id); this.lastPosition = null; this.delegatedEvents = {}; @@ -416,12 +418,14 @@ export class InputMouse { this.lastPosition = this.getRelativeMousePosition(event); this.leftMouseButton.handleMouseDown(event); + this.middleMouseButton.handleMouseDown(event); this.rightMouseButton.handleMouseDown(event); }; mouseUp = (event: MouseEvent): void => { isDragging = false; this.leftMouseButton.handleMouseUp(event, this.triggeredByTouch); + this.middleMouseButton.handleMouseUp(event, this.triggeredByTouch); this.rightMouseButton.handleMouseUp(event, this.triggeredByTouch); this.triggeredByTouch = false; @@ -458,6 +462,7 @@ export class InputMouse { if (delta != null && (delta.x !== 0 || delta.y !== 0)) { this.leftMouseButton.handleMouseMove(event, delta); + this.middleMouseButton.handleMouseMove(event, delta); this.rightMouseButton.handleMouseMove(event, delta); if (this.isHit(event)) { this.trigger("mouseMove", delta, this.position, this.id, event); diff --git a/frontend/javascripts/libs/react_hooks.js b/frontend/javascripts/libs/react_hooks.js new file mode 100644 index 00000000000..9f62d51d5fc --- /dev/null +++ b/frontend/javascripts/libs/react_hooks.js @@ -0,0 +1,87 @@ +// @flow +import { useState, useEffect, useRef } from "react"; + +// Adapted from: https://usehooks.com/usePrevious/ +export function usePrevious(value: T): ?T { + // The ref object is a generic container whose current property is mutable ... + // ... and can hold any value, similar to an instance property on a class + const ref = useRef(null); + + // Store current value in ref + useEffect(() => { + ref.current = value; + }, [value]); // Only re-run if value changes + + // Return previous value (happens before update in useEffect above) + return ref.current; +} + +const extractModifierState = event => ({ + Shift: event.shiftKey, + Alt: event.altKey, + Control: event.ctrlKey, +}); + +// Adapted from: https://gist.github.com/gragland/b61b8f46114edbcf2a9e4bd5eb9f47f5 +export function useKeyPress(targetKey: string) { + // State for keeping track of whether key is pressed + const [keyPressed, setKeyPressed] = useState(false); + + // If pressed key is our target key then set to true + function downHandler(event) { + const modifierState = extractModifierState(event); + + if (modifierState[targetKey] === undefined) { + // The targetKey is not a modifier. Compare to the pressed key. + if (event.key === targetKey) { + pressKey(); + } + } else if (modifierState[targetKey]) { + // Use the modifierState as this seems to be more robust. See + // the other comment below which describes some edge cases + // regarding modifiers. + pressKey(); + } + } + + function pressKey() { + setKeyPressed(true); + window.addEventListener("blur", releaseKey); + } + + function releaseKey() { + setKeyPressed(false); + window.removeEventListener("blur", releaseKey); + } + + // If released key is our target key then set to false + const upHandler = event => { + const modifierState = extractModifierState(event); + + if (modifierState[targetKey] === undefined) { + // The targetKey is not a modifier. Compare to the pressed key. + if (event.key === targetKey) { + releaseKey(); + } + } else if (!modifierState[targetKey]) { + // The targetKey is a modifier. Use the modifierState as this + // is more robust against pressing multiple modifiers. For example, + // on Linux, pressing Shift and then toggling Alt, will send a release + // of the Meta key even though that key was never touched. + releaseKey(); + } + }; + + // Add event listeners + useEffect(() => { + window.addEventListener("keydown", downHandler); + window.addEventListener("keyup", upHandler); + // Remove event listeners on cleanup + return () => { + window.removeEventListener("keydown", downHandler); + window.removeEventListener("keyup", upHandler); + }; + }, []); // Empty array ensures that effect is only run on mount and unmount + + return keyPressed; +} diff --git a/frontend/javascripts/oxalis/constants.js b/frontend/javascripts/oxalis/constants.js index 1b4a059b753..3b0770538d4 100644 --- a/frontend/javascripts/oxalis/constants.js +++ b/frontend/javascripts/oxalis/constants.js @@ -94,9 +94,12 @@ export type ControlMode = $Keys; export const VolumeToolEnum = { MOVE: "MOVE", - TRACE: "TRACE", BRUSH: "BRUSH", + TRACE: "TRACE", + FILL_CELL: "FILL_CELL", + PICK_CELL: "PICK_CELL", }; +export const ToolsWithOverwriteCapabilities = [VolumeToolEnum.TRACE, VolumeToolEnum.BRUSH]; export type VolumeTool = $Keys; export function volumeToolEnumToIndex(volumeTool: ?VolumeTool): number { @@ -104,14 +107,18 @@ export function volumeToolEnumToIndex(volumeTool: ?VolumeTool): number { } export const ContourModeEnum = { - IDLE: "IDLE", DRAW: "DRAW", - DRAW_OVERWRITE: "DRAW_OVERWRITE", - DELETE_FROM_ACTIVE_CELL: "DELETE_FROM_ACTIVE_CELL", - DELETE_FROM_ANY_CELL: "DELETE_FROM_ANY_CELL", + DELETE: "DELETE", }; export type ContourMode = $Keys; +export const OverwriteModeEnum = { + OVERWRITE_ALL: "OVERWRITE_ALL", + OVERWRITE_EMPTY: "OVERWRITE_EMPTY", // In case of deleting, empty === current cell id +}; + +export type OverwriteMode = $Keys; + export const NODE_ID_REF_REGEX = /#([0-9]+)/g; export const POSITION_REF_REGEX = /#\(([0-9]+,[0-9]+,[0-9]+)\)/g; diff --git a/frontend/javascripts/oxalis/controller/combinations/skeletontracing_plane_controller.js b/frontend/javascripts/oxalis/controller/combinations/skeletontracing_plane_controller.js index dc73b9067cd..2fd471583a1 100644 --- a/frontend/javascripts/oxalis/controller/combinations/skeletontracing_plane_controller.js +++ b/frontend/javascripts/oxalis/controller/combinations/skeletontracing_plane_controller.js @@ -12,7 +12,7 @@ import { VolumeToolEnum, } from "oxalis/constants"; import { V3 } from "libs/mjs"; -import { calculateGlobalPos } from "oxalis/controller/viewmodes/plane_controller"; +import { movePlane, calculateGlobalPos } from "oxalis/controller/viewmodes/plane_controller"; import { enforce } from "libs/utils"; import { enforceSkeletonTracing, @@ -41,10 +41,7 @@ import { setNodePositionAction, updateNavigationListAction, } from "oxalis/model/actions/skeletontracing_actions"; -import { - setDirectionAction, - movePlaneFlycamOrthoAction, -} from "oxalis/model/actions/flycam_actions"; +import { setDirectionAction } from "oxalis/model/actions/flycam_actions"; import type PlaneView from "oxalis/view/plane_view"; import Store from "oxalis/store"; import type { Edge, Tree, Node } from "oxalis/store"; @@ -79,20 +76,17 @@ export function getPlaneMouseControls(planeView: PlaneView) { return { leftDownMove: (delta: Point2, pos: Point2, _id: ?string, event: MouseEvent) => { const { tracing } = Store.getState(); - const state = Store.getState(); if (tracing.skeleton != null && event.ctrlKey) { moveNode(delta.x, delta.y); } else { - const { activeViewport } = state.viewModeData.plane; - const v = [-delta.x, -delta.y, 0]; - Store.dispatch(movePlaneFlycamOrthoAction(v, activeViewport, true)); + movePlane([-delta.x, -delta.y, 0]); } }, leftClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => onClick(planeView, pos, event.shiftKey, event.altKey, event.ctrlKey, plane, isTouch, event), rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent) => { const { volume } = Store.getState().tracing; - if (!volume || volume.activeTool !== VolumeToolEnum.BRUSH) { + if (!volume || volume.activeTool === VolumeToolEnum.MOVE) { // We avoid creating nodes when in brushing mode. setWaypoint(calculateGlobalPos(pos), event.ctrlKey); } @@ -354,13 +348,10 @@ function setWaypoint(position: Vector3, ctrlPressed: boolean): void { const rotation = getRotationOrtho(activeViewport); addNode(position, rotation, !ctrlPressed); - // Strg + Rightclick to set new not active branchpoint + // Ctrl + right click to set new not active branchpoint const { newNodeNewTree } = Store.getState().userConfiguration; if (ctrlPressed && !newNodeNewTree) { Store.dispatch(createBranchPointAction()); - activeNodeMaybe.map(activeNode => { - Store.dispatch(setActiveNodeAction(activeNode.id)); - }); } } @@ -384,6 +375,8 @@ function addNode(position: Vector3, rotation: Vector3, centered: boolean): void rotation, OrthoViewToNumber[Store.getState().viewModeData.plane.activeViewport], getRequestLogZoomStep(state), + null, + !centered, ), ); diff --git a/frontend/javascripts/oxalis/controller/combinations/volumetracing_plane_controller.js b/frontend/javascripts/oxalis/controller/combinations/volumetracing_plane_controller.js index 67d3d137051..ea8ce8f1584 100644 --- a/frontend/javascripts/oxalis/controller/combinations/volumetracing_plane_controller.js +++ b/frontend/javascripts/oxalis/controller/combinations/volumetracing_plane_controller.js @@ -86,8 +86,7 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { if ( (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH) && - (contourTracingMode === ContourModeEnum.DRAW || - contourTracingMode === ContourModeEnum.DRAW_OVERWRITE) + contourTracingMode === ContourModeEnum.DRAW ) { Store.dispatch(addToLayerAction(calculateGlobalPos(pos))); } @@ -97,23 +96,16 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { const tool = Utils.enforce(getVolumeTool)(Store.getState().tracing.volume); if (!event.shiftKey && (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH)) { - if (event.ctrlKey) { - if (isAutomaticBrushEnabled()) { - return; - } - Store.dispatch(setContourTracingModeAction(ContourModeEnum.DRAW)); - } else { - Store.dispatch(setContourTracingModeAction(ContourModeEnum.DRAW_OVERWRITE)); + if (event.ctrlKey && isAutomaticBrushEnabled()) { + return; } + Store.dispatch(setContourTracingModeAction(ContourModeEnum.DRAW)); Store.dispatch(startEditingAction(calculateGlobalPos(pos), plane)); } }, leftMouseUp: () => { const tool = Utils.enforce(getVolumeTool)(Store.getState().tracing.volume); - - Store.dispatch(setContourTracingModeAction(ContourModeEnum.IDLE)); - if (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH) { Store.dispatch(finishEditingAction()); } @@ -127,8 +119,7 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { if ( (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH) && - (contourTracingMode === ContourModeEnum.DELETE_FROM_ACTIVE_CELL || - contourTracingMode === ContourModeEnum.DELETE_FROM_ANY_CELL) + contourTracingMode === ContourModeEnum.DELETE ) { Store.dispatch(addToLayerAction(calculateGlobalPos(pos))); } @@ -138,11 +129,7 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { const tool = Utils.enforce(getVolumeTool)(Store.getState().tracing.volume); if (!event.shiftKey && (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH)) { - if (event.ctrlKey) { - Store.dispatch(setContourTracingModeAction(ContourModeEnum.DELETE_FROM_ANY_CELL)); - } else { - Store.dispatch(setContourTracingModeAction(ContourModeEnum.DELETE_FROM_ACTIVE_CELL)); - } + Store.dispatch(setContourTracingModeAction(ContourModeEnum.DELETE)); Store.dispatch(startEditingAction(calculateGlobalPos(pos), plane)); } }, @@ -150,16 +137,20 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { rightMouseUp: () => { const tool = Utils.enforce(getVolumeTool)(Store.getState().tracing.volume); - Store.dispatch(setContourTracingModeAction(ContourModeEnum.IDLE)); - if (tool === VolumeToolEnum.TRACE || tool === VolumeToolEnum.BRUSH) { Store.dispatch(finishEditingAction()); - Store.dispatch(setContourTracingModeAction(ContourModeEnum.IDLE)); } }, leftClick: (pos: Point2, plane: OrthoView, event: MouseEvent) => { - if (event.shiftKey && !event.ctrlKey) { + const tool = Utils.enforce(getVolumeTool)(Store.getState().tracing.volume); + + const shouldPickCell = + tool === VolumeToolEnum.PICK_CELL || (event.shiftKey && !event.ctrlKey); + + const shouldFillCell = tool === VolumeToolEnum.FILL_CELL || (event.shiftKey && event.ctrlKey); + + if (shouldPickCell) { const segmentation = Model.getSegmentationLayer(); if (!segmentation) { return; @@ -172,7 +163,7 @@ export function getPlaneMouseControls(_planeId: OrthoView): * { Store.dispatch(setActiveCellAction(cellId)); isosurfaceLeftClick(pos, plane, event); } - } else if (event.shiftKey && event.ctrlKey) { + } else if (shouldFillCell) { Store.dispatch(floodFillAction(calculateGlobalPos(pos), plane)); } else if (event.metaKey) { if (isAutomaticBrushEnabled()) { diff --git a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js index 8956f42c89f..dc7dad91571 100644 --- a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js +++ b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js @@ -75,6 +75,11 @@ type StateProps = {| |}; type Props = {| ...StateProps |}; +export const movePlane = (v: Vector3, increaseSpeedWithZoom: boolean = true) => { + const { activeViewport } = Store.getState().viewModeData.plane; + Store.dispatch(movePlaneFlycamOrthoAction(v, activeViewport, increaseSpeedWithZoom)); +}; + class PlaneController extends React.PureComponent { // See comment in Controller class on general controller architecture. // @@ -144,7 +149,7 @@ class PlaneController extends React.PureComponent { } getPlaneMouseControls(planeId: OrthoView): Object { - const defaultDragHandler = (delta: Point2) => this.movePlane([-delta.x, -delta.y, 0]); + const defaultDragHandler = (delta: Point2) => movePlane([-delta.x, -delta.y, 0]); const baseControls = { scroll: this.scrollPlanes.bind(this), over: () => { @@ -153,11 +158,12 @@ class PlaneController extends React.PureComponent { pinch: delta => this.zoom(delta, true), mouseMove: (delta: Point2, position: Point2, id, event) => { if (event.altKey && !event.shiftKey) { - this.movePlane([-delta.x, -delta.y, 0]); + movePlane([-delta.x, -delta.y, 0]); } else { Store.dispatch(setMousePositionAction([position.x, position.y])); } }, + middleDownMove: defaultDragHandler, }; // TODO: Find a nicer way to express this, while satisfying flow const emptyDefaultHandler = { leftClick: null, leftDownMove: null }; @@ -370,17 +376,12 @@ class PlaneController extends React.PureComponent { getSceneController().update(); } - movePlane = (v: Vector3, increaseSpeedWithZoom: boolean = true) => { - const { activeViewport } = Store.getState().viewModeData.plane; - Store.dispatch(movePlaneFlycamOrthoAction(v, activeViewport, increaseSpeedWithZoom)); - }; - moveX = (x: number): void => { - this.movePlane([x, 0, 0]); + movePlane([x, 0, 0]); }; moveY = (y: number): void => { - this.movePlane([0, y, 0]); + movePlane([0, y, 0]); }; moveZ = (z: number, oneSlide: boolean): void => { @@ -404,7 +405,7 @@ class PlaneController extends React.PureComponent { ), ); } else { - this.movePlane([0, 0, z], false); + movePlane([0, 0, z], false); } }; diff --git a/frontend/javascripts/oxalis/geometries/contourgeometry.js b/frontend/javascripts/oxalis/geometries/contourgeometry.js index 2452bd47dbe..a068fb93e86 100644 --- a/frontend/javascripts/oxalis/geometries/contourgeometry.js +++ b/frontend/javascripts/oxalis/geometries/contourgeometry.js @@ -32,10 +32,7 @@ class ContourGeometry { // Update meshes according to the new contourList this.reset(); this.color = - tracing.contourTracingMode === ContourModeEnum.DELETE_FROM_ANY_CELL || - tracing.contourTracingMode === ContourModeEnum.DELETE_FROM_ACTIVE_CELL - ? COLOR_DELETE - : COLOR_NORMAL; + tracing.contourTracingMode === ContourModeEnum.DELETE ? COLOR_DELETE : COLOR_NORMAL; contourList.forEach(p => this.addEdgePoint(p)); } lastContourList = contourList; diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js index 330077c2519..97c8a3fec9a 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js @@ -30,7 +30,8 @@ type CreateNodeAction = { viewport: number, resolution: number, timestamp: number, - treeId?: number, + treeId?: ?number, + dontActivate?: boolean, }; type DeleteNodeAction = { type: "DELETE_NODE", @@ -215,7 +216,8 @@ export const createNodeAction = ( rotation: Vector3, viewport: number, resolution: number, - treeId?: number, + treeId?: ?number, + dontActivate: boolean = false, timestamp: number = Date.now(), ): CreateNodeAction => ({ type: "CREATE_NODE", @@ -224,6 +226,7 @@ export const createNodeAction = ( viewport, resolution, treeId, + dontActivate, timestamp, }); diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js index c4500383416..1731c9f9a33 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js @@ -10,6 +10,7 @@ import type { OrthoView, VolumeTool, ContourMode, + OverwriteMode, } from "oxalis/constants"; import type { BucketDataArray } from "oxalis/model/bucket_data_handling/bucket"; @@ -40,6 +41,7 @@ export type FinishAnnotationStrokeAction = { type: "FINISH_ANNOTATION_STROKE" }; type SetMousePositionAction = { type: "SET_MOUSE_POSITION", position: Vector2 }; type HideBrushAction = { type: "HIDE_BRUSH" }; type SetContourTracingModeAction = { type: "SET_CONTOUR_TRACING_MODE", mode: ContourMode }; +type SetOverwriteModeAction = { type: "SET_OVERWRITE_MODE", mode: OverwriteMode }; export type InferSegmentationInViewportAction = { type: "INFER_SEGMENT_IN_VIEWPORT", position: Vector3, @@ -66,6 +68,7 @@ export type VolumeTracingAction = | CopySegmentationLayerAction | InferSegmentationInViewportAction | SetContourTracingModeAction + | SetOverwriteModeAction | AddBucketToUndoAction | RemoveFallbackLayerAction | ImportVolumeTracingAction @@ -160,6 +163,11 @@ export const setContourTracingModeAction = (mode: ContourMode): SetContourTracin mode, }); +export const setOverwriteModeAction = (mode: OverwriteMode): SetOverwriteModeAction => ({ + type: "SET_OVERWRITE_MODE", + mode, +}); + export const addBucketToUndoAction = ( zoomedBucketAddress: Vector4, bucketData: BucketDataArray, diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js index e62d0380a2a..dc75d2cee99 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js @@ -133,16 +133,20 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState nodes: { $set: newDiffableMap }, edges: { $set: edges }, }); + const activeNodeId = action.dontActivate ? skeletonTracing.activeNodeId : node.id; + const activeTreeId = action.dontActivate + ? skeletonTracing.activeTreeId + : tree.treeId; return update(state, { tracing: { skeleton: { trees: { [tree.treeId]: { $set: newTree }, }, - activeNodeId: { $set: node.id }, + activeNodeId: { $set: activeNodeId }, activeGroupId: { $set: null }, cachedMaxNodeId: { $set: node.id }, - activeTreeId: { $set: tree.treeId }, + activeTreeId: { $set: activeTreeId }, }, }, }); diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.js b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.js index 0878a8f1a04..e36e3fffb9a 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.js @@ -5,7 +5,7 @@ import update from "immutability-helper"; import type { OxalisState, VolumeTracing } from "oxalis/store"; -import { VolumeToolEnum, ContourModeEnum } from "oxalis/constants"; +import { VolumeToolEnum, ContourModeEnum, OverwriteModeEnum } from "oxalis/constants"; import type { VolumeTracingAction } from "oxalis/model/actions/volumetracing_actions"; import { convertServerBoundingBoxToFrontend, @@ -19,6 +19,7 @@ import { updateDirectionReducer, addToLayerReducer, resetContourReducer, + setOverwriteModeModeReducer, hideBrushReducer, setContourTracingModeReducer, removeFallbackLayerReducer, @@ -39,7 +40,8 @@ function VolumeTracingReducer(state: OxalisState, action: VolumeTracingAction): type: "volume", activeCellId: 0, lastCentroid: null, - contourTracingMode: ContourModeEnum.IDLE, + contourTracingMode: ContourModeEnum.DRAW, + overwriteMode: OverwriteModeEnum.OVERWRITE_ALL, contourList: [], maxCellId, cells: {}, @@ -107,6 +109,10 @@ function VolumeTracingReducer(state: OxalisState, action: VolumeTracingAction): return setContourTracingModeReducer(state, action.mode); } + case "SET_OVERWRITE_MODE": { + return setOverwriteModeModeReducer(state, action.mode); + } + case "REMOVE_FALLBACK_LAYER": { return removeFallbackLayerReducer(state); } diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.js b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.js index da327830af9..9e579cb01bc 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.js +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.js @@ -8,7 +8,13 @@ import update from "immutability-helper"; -import { type ContourMode, type Vector3, type VolumeTool, VolumeToolEnum } from "oxalis/constants"; +import { + type ContourMode, + type OverwriteMode, + type Vector3, + type VolumeTool, + VolumeToolEnum, +} from "oxalis/constants"; import type { OxalisState, VolumeTracing, VolumeCell } from "oxalis/store"; import { isVolumeTracingDisallowed } from "oxalis/model/accessors/volumetracing_accessor"; import { setDirectionReducer } from "oxalis/model/reducers/flycam_reducer"; @@ -140,6 +146,16 @@ export function setContourTracingModeReducer(state: OxalisState, mode: ContourMo }); } +export function setOverwriteModeModeReducer(state: OxalisState, mode: OverwriteMode) { + return update(state, { + tracing: { + volume: { + overwriteMode: { $set: mode }, + }, + }, + }); +} + export function removeFallbackLayerReducer(state: OxalisState) { return update(state, { tracing: { diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js index 407737d1818..0520c61d717 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js @@ -39,7 +39,9 @@ import { import { type BoundingBoxType, type ContourMode, + type OverwriteMode, ContourModeEnum, + OverwriteModeEnum, type OrthoView, type VolumeTool, type Vector2, @@ -93,9 +95,8 @@ export function* editVolumeLayerAsync(): Generator { const contourTracingMode = yield* select( state => enforceVolumeTracing(state.tracing).contourTracingMode, ); - const isDrawing = - contourTracingMode === ContourModeEnum.DRAW_OVERWRITE || - contourTracingMode === ContourModeEnum.DRAW; + const overwriteMode = yield* select(state => enforceVolumeTracing(state.tracing).overwriteMode); + const isDrawing = contourTracingMode === ContourModeEnum.DRAW; // Volume tracing for higher zoomsteps is currently not allowed if (yield* select(state => isVolumeTracingDisallowed(state))) { @@ -111,6 +112,7 @@ export function* editVolumeLayerAsync(): Generator { labelWithIterator, currentLayer.getCircleVoxelIterator(startEditingAction.position, activeViewportBounding), contourTracingMode, + overwriteMode, ); } @@ -145,17 +147,18 @@ export function* editVolumeLayerAsync(): Generator { currentViewportBounding, ); if (rectangleIterator) { - yield* call(labelWithIterator, rectangleIterator, contourTracingMode); + yield* call(labelWithIterator, rectangleIterator, contourTracingMode, overwriteMode); } yield* call( labelWithIterator, currentLayer.getCircleVoxelIterator(addToLayerAction.position, currentViewportBounding), contourTracingMode, + overwriteMode, ); } lastPosition = addToLayerAction.position; } - yield* call(finishLayer, currentLayer, activeTool, contourTracingMode); + yield* call(finishLayer, currentLayer, activeTool, contourTracingMode, overwriteMode); yield* put(finishAnnotationStrokeAction()); } } @@ -176,7 +179,11 @@ function* createVolumeLayer(planeId: OrthoView): Saga { return new VolumeLayer(planeId, thirdDimValue); } -function* labelWithIterator(iterator, contourTracingMode): Saga { +function* labelWithIterator( + iterator, + contourTracingMode, + overwriteMode: OverwriteMode, +): Saga { const allowUpdate = yield* select(state => state.tracing.restrictions.allowUpdate); if (!allowUpdate) return; @@ -184,17 +191,20 @@ function* labelWithIterator(iterator, contourTracingMode): Saga { const segmentationLayer = yield* call([Model, Model.getSegmentationLayer]); const { cube } = segmentationLayer; switch (contourTracingMode) { - case ContourModeEnum.DRAW_OVERWRITE: - yield* call([cube, cube.labelVoxels], iterator, activeCellId); - break; case ContourModeEnum.DRAW: - yield* call([cube, cube.labelVoxels], iterator, activeCellId, 0); - break; - case ContourModeEnum.DELETE_FROM_ACTIVE_CELL: - yield* call([cube, cube.labelVoxels], iterator, 0, activeCellId); + if (overwriteMode === OverwriteModeEnum.OVERWRITE_ALL) { + yield* call([cube, cube.labelVoxels], iterator, activeCellId); + } else { + yield* call([cube, cube.labelVoxels], iterator, activeCellId, 0); + } + break; - case ContourModeEnum.DELETE_FROM_ANY_CELL: - yield* call([cube, cube.labelVoxels], iterator, 0); + case ContourModeEnum.DELETE: + if (overwriteMode === OverwriteModeEnum.OVERWRITE_ALL) { + yield* call([cube, cube.labelVoxels], iterator, 0); + } else { + yield* call([cube, cube.labelVoxels], iterator, 0, activeCellId); + } break; default: throw new Error("Invalid volume tracing mode."); @@ -302,13 +312,19 @@ export function* finishLayer( layer: VolumeLayer, activeTool: VolumeTool, contourTracingMode: ContourMode, + overwriteMode: OverwriteMode, ): Saga { if (layer == null || layer.isEmpty()) { return; } if (activeTool === VolumeToolEnum.TRACE || activeTool === VolumeToolEnum.BRUSH) { - yield* call(labelWithIterator, layer.getVoxelIterator(activeTool), contourTracingMode); + yield* call( + labelWithIterator, + layer.getVoxelIterator(activeTool), + contourTracingMode, + overwriteMode, + ); } yield* put(updateDirectionAction(layer.getCentroid())); diff --git a/frontend/javascripts/oxalis/shaders/segmentation.glsl.js b/frontend/javascripts/oxalis/shaders/segmentation.glsl.js index 0f37080e77b..f8b4393b433 100644 --- a/frontend/javascripts/oxalis/shaders/segmentation.glsl.js +++ b/frontend/javascripts/oxalis/shaders/segmentation.glsl.js @@ -123,9 +123,13 @@ export const convertCellIdToRGB: ShaderModule = { `, }; -// This function mirrors convertCellIdToRGB in the fragment shader of the rendering plane. +// This function mirrors the above convertCellIdToRGB-function. // Output is in [0,1] for H, S, L and A -export const jsConvertCellIdToHSLA = (id: number, customColors: ?Array): Array => { +export const jsConvertCellIdToHSLA = ( + id: number, + customColors?: ?Array, + alpha: number = 1, +): Array => { if (id === 0) { // Return white return [1, 1, 1, 1]; @@ -146,7 +150,7 @@ export const jsConvertCellIdToHSLA = (id: number, customColors: ?Array): hue = (1 / 360) * jsRgb2hsv(jsColormapJet(colorValueDecimal))[0]; } - return [hue, 1, 0.5, 0.15]; + return [hue, 1, 0.5, alpha]; }; export const getBrushOverlay: ShaderModule = { diff --git a/frontend/javascripts/oxalis/store.js b/frontend/javascripts/oxalis/store.js index 9b6289118f7..e0bf520cd53 100644 --- a/frontend/javascripts/oxalis/store.js +++ b/frontend/javascripts/oxalis/store.js @@ -33,6 +33,7 @@ import AnnotationReducer from "oxalis/model/reducers/annotation_reducer"; import { type BoundingBoxType, type ContourMode, + type OverwriteMode, type ControlMode, ControlModeEnum, type ViewMode, @@ -222,6 +223,9 @@ export type VolumeTracing = {| +activeCellId: number, +lastCentroid: ?Vector3, +contourTracingMode: ContourMode, + // This overwrite mode is used when no modifier is pressed. + // Pressing the modifier, will toggle the mode + +overwriteMode: OverwriteMode, +contourList: Array, +cells: VolumeCellMap, +fallbackLayer?: string, diff --git a/frontend/javascripts/oxalis/view/action-bar/save_button.js b/frontend/javascripts/oxalis/view/action-bar/save_button.js index 1e7bbc2b840..e52d31ef9a9 100644 --- a/frontend/javascripts/oxalis/view/action-bar/save_button.js +++ b/frontend/javascripts/oxalis/view/action-bar/save_button.js @@ -3,7 +3,8 @@ import { connect } from "react-redux"; import React from "react"; import _ from "lodash"; -import type { OxalisState, ProgressInfo, IsBusyInfo } from "oxalis/store"; +import Store from "oxalis/store"; +import type { OxalisState, IsBusyInfo } from "oxalis/store"; import { isBusy } from "oxalis/model/accessors/save_accessor"; import ButtonComponent from "oxalis/view/components/button_component"; import Model from "oxalis/model"; @@ -16,15 +17,14 @@ type OwnProps = {| className?: string, |}; type StateProps = {| - progressInfo: ProgressInfo, + progressFraction: ?number, isBusyInfo: IsBusyInfo, - oldestUnsavedTimestamp: ?number, |}; type Props = {| ...OwnProps, ...StateProps |}; type State = { isStateSaved: boolean, - currentTimestamp: number, + showUnsavedWarning: boolean, }; const SAVE_POLLING_INTERVAL = 1000; // 1s @@ -45,7 +45,7 @@ class SaveButton extends React.PureComponent { savedPollingInterval: number = 0; state = { isStateSaved: false, - currentTimestamp: Date.now(), + showUnsavedWarning: false, }; componentDidMount() { @@ -59,9 +59,18 @@ class SaveButton extends React.PureComponent { _forceUpdate = () => { const isStateSaved = Model.stateSaved(); + const oldestUnsavedTimestamp = getOldestUnsavedTimestamp(Store.getState().save.queue); + + const unsavedDuration = + oldestUnsavedTimestamp != null ? new Date() - oldestUnsavedTimestamp : 0; + const showUnsavedWarning = unsavedDuration > UNSAVED_WARNING_THRESHOLD; + if (showUnsavedWarning) { + reportUnsavedDurationThresholdExceeded(); + } + this.setState({ isStateSaved, - currentTimestamp: Date.now(), + showUnsavedWarning, }); }; @@ -76,18 +85,12 @@ class SaveButton extends React.PureComponent { } shouldShowProgress(): boolean { - // For a low action count, the progress info would show only for a very short amount of time - return isBusy(this.props.isBusyInfo) && this.props.progressInfo.totalActionCount > 5000; + return isBusy(this.props.isBusyInfo) && this.props.progressFraction != null; } render() { - const { progressInfo, oldestUnsavedTimestamp } = this.props; - const unsavedDuration = - oldestUnsavedTimestamp != null ? this.state.currentTimestamp - oldestUnsavedTimestamp : 0; - const showUnsavedWarning = unsavedDuration > UNSAVED_WARNING_THRESHOLD; - if (showUnsavedWarning) { - reportUnsavedDurationThresholdExceeded(); - } + const { progressFraction } = this.props; + const showUnsavedWarning = this.state.showUnsavedWarning; return ( { style={{ background: showUnsavedWarning ? "#e33f36" : null }} > {this.shouldShowProgress() ? ( - - {Math.floor((progressInfo.processedActionCount / progressInfo.totalActionCount) * 100)}{" "} - % - + {Math.floor((progressFraction || 0) * 100)} % ) : ( Save )} @@ -139,12 +139,15 @@ function getOldestUnsavedTimestamp(saveQueue): ?number { function mapStateToProps(state: OxalisState): StateProps { const { progressInfo, isBusyInfo } = state.save; - const oldestUnsavedTimestamp = getOldestUnsavedTimestamp(state.save.queue); return { - progressInfo, isBusyInfo, - oldestUnsavedTimestamp, + // For a low action count, the progress info would show only for a very short amount of time. + // Therefore, the progressFraction is set to null, if the count is low. + progressFraction: + progressInfo.totalActionCount > 5000 + ? progressInfo.processedActionCount / progressInfo.totalActionCount + : null, }; } diff --git a/frontend/javascripts/oxalis/view/action-bar/volume_actions_view.js b/frontend/javascripts/oxalis/view/action-bar/volume_actions_view.js index e87833612f1..fcd29a59b82 100644 --- a/frontend/javascripts/oxalis/view/action-bar/volume_actions_view.js +++ b/frontend/javascripts/oxalis/view/action-bar/volume_actions_view.js @@ -1,84 +1,301 @@ // @flow -import { Button, Radio, Tooltip } from "antd"; -import { connect } from "react-redux"; -import React, { PureComponent } from "react"; +import { Button, Radio, Tooltip, Badge } from "antd"; +import { useSelector } from "react-redux"; +import React, { useEffect } from "react"; +import { usePrevious, useKeyPress } from "libs/react_hooks"; +import Model from "oxalis/model"; -import { type VolumeTool, VolumeToolEnum } from "oxalis/constants"; +import { + ToolsWithOverwriteCapabilities, + type VolumeTool, + VolumeToolEnum, + type OverwriteMode, + OverwriteModeEnum, +} from "oxalis/constants"; import { document } from "libs/window"; import { enforceVolumeTracing } from "oxalis/model/accessors/volumetracing_accessor"; -import { setToolAction, createCellAction } from "oxalis/model/actions/volumetracing_actions"; +import { + setToolAction, + createCellAction, + setOverwriteModeAction, +} from "oxalis/model/actions/volumetracing_actions"; import ButtonComponent from "oxalis/view/components/button_component"; -import Store, { type OxalisState } from "oxalis/store"; +import Store from "oxalis/store"; +import { convertCellIdToCSS } from "oxalis/view/right-menu/mapping_info_view"; -// Workaround until github.com/facebook/flow/issues/1113 is fixed -const RadioGroup = Radio.Group; -const RadioButton = Radio.Button; -const ButtonGroup = Button.Group; +function getMoveToolHint(activeTool, isShiftPressed, isControlPressed, isAltPressed): ?string { + if (activeTool !== VolumeToolEnum.MOVE) { + return null; + } -type Props = {| - activeTool: VolumeTool, - isInMergerMode: boolean, -|}; + if (!isShiftPressed && !isControlPressed && !isAltPressed) { + return null; + } -class VolumeActionsView extends PureComponent { - componentDidUpdate = (prevProps: Props) => { - if (!prevProps.isInMergerMode && this.props.isInMergerMode) { - Store.dispatch(setToolAction(VolumeToolEnum.MOVE)); + if (isShiftPressed && !isControlPressed && !isAltPressed) { + return "Click to select a node."; + } + + if (!isShiftPressed && isControlPressed && !isAltPressed) { + return "Drag to move the selected node. Right-click to create a new node without selecting it."; + } + + if (isShiftPressed && !isControlPressed && isAltPressed) { + return "Click on a node in another tree to merge the two trees."; + } + + if (isShiftPressed && isControlPressed && !isAltPressed) { + return "Click on a node to delete the edge to the currently active node."; + } + + return null; +} + +function adaptActiveToolToShortcuts( + activeTool, + isShiftPressed, + isControlPressed, + isAltPressed, +): VolumeTool { + if (!isShiftPressed && !isControlPressed && !isAltPressed) { + // No modifier is pressed + return activeTool; + } + + if (activeTool === VolumeToolEnum.MOVE) { + // The "skeleton" tool is not changed right now (since actions such as moving a node + // don't have a dedicated tool) + return activeTool; + } + + if (isShiftPressed && !isControlPressed && !isAltPressed) { + // Only shift is pressed. Switch to the picker + return VolumeToolEnum.PICK_CELL; + } + + if (isControlPressed && isShiftPressed && !isAltPressed) { + // Control and shift invoke the fill cell tool + return VolumeToolEnum.FILL_CELL; + } + + if (isAltPressed) { + // Alt switches to the move tool + return VolumeToolEnum.MOVE; + } + + return activeTool; +} + +function toggleOverwriteMode(overwriteMode) { + if (overwriteMode === OverwriteModeEnum.OVERWRITE_ALL) { + return OverwriteModeEnum.OVERWRITE_EMPTY; + } else { + return OverwriteModeEnum.OVERWRITE_ALL; + } +} + +const narrowButtonStyle = { + paddingLeft: 10, + width: 38, +}; + +const handleSetTool = (event: { target: { value: VolumeTool } }) => { + Store.dispatch(setToolAction(event.target.value)); +}; + +const handleCreateCell = () => { + Store.dispatch(createCellAction()); +}; + +const handleSetOverwriteMode = (event: { target: { value: OverwriteMode } }) => { + Store.dispatch(setOverwriteModeAction(event.target.value)); +}; + +const RadioButtonWithTooltip = ({ + title, + disabledTitle, + disabled, + ...props +}: { + title: string, + disabledTitle?: string, + disabled?: boolean, +}) => ( + + {/* $FlowIgnore[cannot-spread-inexact] */} + + +); + +function OverwriteModeSwitch({ isControlPressed }) { + const overwriteMode = useSelector(state => enforceVolumeTracing(state.tracing).overwriteMode); + const previousIsControlPressed = usePrevious(isControlPressed); + const didControlChange = + previousIsControlPressed != null && isControlPressed !== previousIsControlPressed; + useEffect(() => { + if (didControlChange) { + Store.dispatch(setOverwriteModeAction(toggleOverwriteMode(overwriteMode))); } - }; - - handleSetTool = (event: { target: { value: VolumeTool } }) => { - Store.dispatch(setToolAction(event.target.value)); - }; - - handleCreateCell = () => { - Store.dispatch(createCellAction()); - }; - - render() { - return ( -
{ - if (document.activeElement) document.activeElement.blur(); - }} + }, [didControlChange]); + + return ( + + - + + + Overwrite Empty Icon + + + ); +} + +const mapId = id => { + const cube = Model.getSegmentationLayer().cube; + return cube.mapId(id); +}; + +export default function VolumeActionsView() { + const hasSkeleton = useSelector(state => state.tracing.skeleton != null); + const activeTool = useSelector(state => enforceVolumeTracing(state.tracing).activeTool); + const unmappedActiveCellId = useSelector( + state => enforceVolumeTracing(state.tracing).activeCellId, + ); + const isMappingEnabled = useSelector( + state => state.temporaryConfiguration.activeMapping.isMappingEnabled, + ); + const isInMergerMode = useSelector(state => state.temporaryConfiguration.isMergerModeEnabled); + const mappingColors = useSelector( + state => state.temporaryConfiguration.activeMapping.mappingColors, + ); + + // Ensure that no volume-tool is selected when being in merger mode. + // Even though, the volume toolbar is disabled, the user can still cycle through + // the tools via the w shortcut. In that case, the effect-hook is re-executed + // and the tool is switched to MOVE. + useEffect(() => { + if (isInMergerMode) { + Store.dispatch(setToolAction(VolumeToolEnum.MOVE)); + } + }, [isInMergerMode, activeTool]); + + const isShiftPressed = useKeyPress("Shift"); + const isControlPressed = useKeyPress("Control"); + const isAltPressed = useKeyPress("Alt"); + + const customColors = isMappingEnabled ? mappingColors : null; + const activeCellId = isMappingEnabled ? mapId(unmappedActiveCellId) : unmappedActiveCellId; + const activeCellColor = convertCellIdToCSS(activeCellId, customColors); + const mappedIdInfo = isMappingEnabled ? ` (currently mapped to ${activeCellId})` : ""; + + const adaptedActiveTool = adaptActiveToolToShortcuts( + activeTool, + isShiftPressed, + isControlPressed, + isAltPressed, + ); + + const moveToolHint = hasSkeleton + ? getMoveToolHint(activeTool, isShiftPressed, isControlPressed, isAltPressed) + : null; + const previousMoveToolHint = usePrevious(moveToolHint); + + const disabledVolumeExplanation = + "Volume annotation is disabled while the merger mode is active."; + + const moveToolDescription = `Pointer – Use left-click to move around${ + hasSkeleton ? " and right-click to create new skeleton nodes" : "" + }.`; + + return ( +
{ + if (document.activeElement) document.activeElement.blur(); + }} + > + + + {/* + When visible changes to false, the tooltip fades out in an animation. However, moveToolHint + will be null, too, which means the tooltip text would immediately change to an empty string. + To avoid this, we fallback to previousMoveToolHint. + */} + + + + + + + + + Trace Tool Icon + + - Move + + + + + + + + {ToolsWithOverwriteCapabilities.includes(adaptedActiveTool) ? ( + + ) : null} + + + - - Trace - - - Brush - + + New Cell Icon + - - - - New  - Cell - - -
- ); - } + + +
+ ); } - -function mapStateToProps(state: OxalisState): Props { - return { - activeTool: enforceVolumeTracing(state.tracing).activeTool, - isInMergerMode: state.temporaryConfiguration.isMergerModeEnabled, - }; -} - -export default connect(mapStateToProps)(VolumeActionsView); diff --git a/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js b/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js index 0e180c8cfc8..cbd80e41e1e 100644 --- a/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js @@ -67,8 +67,8 @@ type State = { }; const convertHSLAToCSSString = ([h, s, l, a]) => `hsla(${360 * h}, ${100 * s}%, ${100 * l}%, ${a})`; -const convertCellIdToCSS = (id, customColors) => - convertHSLAToCSSString(jsConvertCellIdToHSLA(id, customColors)); +export const convertCellIdToCSS = (id: number, customColors: ?Array, alpha?: number) => + convertHSLAToCSSString(jsConvertCellIdToHSLA(id, customColors, alpha)); const hasSegmentation = () => Model.getSegmentationLayer() != null; @@ -226,7 +226,7 @@ class MappingInfoView extends React.Component { unmapped: ( {idInfo.unmapped} @@ -235,7 +235,7 @@ class MappingInfoView extends React.Component { mapped: ( {idInfo.mapped} diff --git a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js index d9decc094c9..5811e0c4662 100644 --- a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js +++ b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js @@ -215,18 +215,29 @@ test("VolumeTracing should not allow to set trace tool if getRequestLogZoomStep( test("VolumeTracing should cycle trace/view/brush tool", t => { const cycleToolAction = VolumeTracingActions.cycleToolAction(); - // Cycle tool to Trace + // Cycle tool to Brush let newState = VolumeTracingReducer(initialState, cycleToolAction); + getVolumeTracing(newState.tracing).map(tracing => { + t.is(tracing.activeTool, VolumeToolEnum.BRUSH); + }); + + // Cycle tool to Trace + newState = VolumeTracingReducer(newState, cycleToolAction); getVolumeTracing(newState.tracing).map(tracing => { t.is(tracing.activeTool, VolumeToolEnum.TRACE); }); - // Cycle tool to Brush newState = VolumeTracingReducer(newState, cycleToolAction); getVolumeTracing(newState.tracing).map(tracing => { - t.is(tracing.activeTool, VolumeToolEnum.BRUSH); + t.is(tracing.activeTool, VolumeToolEnum.FILL_CELL); + }); + + newState = VolumeTracingReducer(newState, cycleToolAction); + + getVolumeTracing(newState.tracing).map(tracing => { + t.is(tracing.activeTool, VolumeToolEnum.PICK_CELL); }); // Cycle tool back to MOVE diff --git a/frontend/javascripts/test/sagas/volumetracing_saga.spec.js b/frontend/javascripts/test/sagas/volumetracing_saga.spec.js index b04b0ecbdfd..495d0c30548 100644 --- a/frontend/javascripts/test/sagas/volumetracing_saga.spec.js +++ b/frontend/javascripts/test/sagas/volumetracing_saga.spec.js @@ -5,7 +5,7 @@ import { take, put, call } from "redux-saga/effects"; import _ from "lodash"; import update from "immutability-helper"; -import { OrthoViews, VolumeToolEnum, ContourModeEnum } from "oxalis/constants"; +import { OrthoViews, VolumeToolEnum, ContourModeEnum, OverwriteModeEnum } from "oxalis/constants"; import { pushSaveQueueTransaction } from "oxalis/model/actions/save_actions"; import * as VolumeTracingActions from "oxalis/model/actions/volumetracing_actions"; import VolumeTracingReducer from "oxalis/model/reducers/volumetracing_reducer"; @@ -49,7 +49,8 @@ const volumeTracing: VolumeTracing = { boundingBox: null, userBoundingBoxes: [], lastCentroid: null, - contourTracingMode: ContourModeEnum.IDLE, + contourTracingMode: ContourModeEnum.DRAW, + overwriteMode: OverwriteModeEnum.OVERWRITE_ALL, }; const initialState = update(defaultState, { @@ -111,7 +112,8 @@ test("VolumeTracingSaga should create a volume layer (saga test)", t => { saga.next(); expectValueDeepEqual(t, saga.next(true), take("START_EDITING")); saga.next(startEditingAction); - saga.next(ContourModeEnum.DRAW_OVERWRITE); + saga.next(ContourModeEnum.DRAW); + saga.next(OverwriteModeEnum.OVERWRITE_ALL); const startEditingSaga = execCall(t, saga.next(false)); startEditingSaga.next(); const layer = startEditingSaga.next([1, 1, 1]).value; @@ -124,7 +126,8 @@ test("VolumeTracingSaga should add values to volume layer (saga test)", t => { saga.next(); expectValueDeepEqual(t, saga.next(true), take("START_EDITING")); saga.next(startEditingAction); - saga.next(ContourModeEnum.DRAW_OVERWRITE); + saga.next(ContourModeEnum.DRAW); + saga.next(OverwriteModeEnum.OVERWRITE_ALL); saga.next(false); const volumeLayer = new VolumeLayer(OrthoViews.PLANE_XY, 10); saga.next(volumeLayer); @@ -147,7 +150,8 @@ test("VolumeTracingSaga should finish a volume layer (saga test)", t => { saga.next(); expectValueDeepEqual(t, saga.next(true), take("START_EDITING")); saga.next(startEditingAction); - saga.next(ContourModeEnum.DRAW_OVERWRITE); + saga.next(ContourModeEnum.DRAW); + saga.next(OverwriteModeEnum.OVERWRITE_ALL); saga.next(false); const volumeLayer = new VolumeLayer(OrthoViews.PLANE_XY, 10); saga.next(volumeLayer); @@ -160,7 +164,13 @@ test("VolumeTracingSaga should finish a volume layer (saga test)", t => { expectValueDeepEqual( t, saga.next({ finishEditingAction }), - call(finishLayer, volumeLayer, VolumeToolEnum.TRACE, ContourModeEnum.DRAW_OVERWRITE), + call( + finishLayer, + volumeLayer, + VolumeToolEnum.TRACE, + ContourModeEnum.DRAW, + OverwriteModeEnum.OVERWRITE_ALL, + ), ); }); @@ -170,7 +180,8 @@ test("VolumeTracingSaga should finish a volume layer in delete mode (saga test)" saga.next(); expectValueDeepEqual(t, saga.next(true), take("START_EDITING")); saga.next(startEditingAction); - saga.next(ContourModeEnum.DELETE_FROM_ACTIVE_CELL); + saga.next(ContourModeEnum.DELETE); + saga.next(OverwriteModeEnum.OVERWRITE_ALL); saga.next(false); const volumeLayer = new VolumeLayer(OrthoViews.PLANE_XY, 10); saga.next(volumeLayer); @@ -183,7 +194,13 @@ test("VolumeTracingSaga should finish a volume layer in delete mode (saga test)" expectValueDeepEqual( t, saga.next({ finishEditingAction }), - call(finishLayer, volumeLayer, VolumeToolEnum.TRACE, ContourModeEnum.DELETE_FROM_ACTIVE_CELL), + call( + finishLayer, + volumeLayer, + VolumeToolEnum.TRACE, + ContourModeEnum.DELETE, + OverwriteModeEnum.OVERWRITE_ALL, + ), ); }); diff --git a/frontend/stylesheets/trace_view/_tracing_view.less b/frontend/stylesheets/trace_view/_tracing_view.less index 9ae4f730ae2..14f43e79195 100644 --- a/frontend/stylesheets/trace_view/_tracing_view.less +++ b/frontend/stylesheets/trace_view/_tracing_view.less @@ -179,7 +179,7 @@ .ant-layout-header { @dark-bg: #383d48; @dark-fg: #f1f1f1; - @dark-border: #67687b; + @dark-border: #585868; @primary-border: #1890ff; @hover-border: #40a9ff; @@ -193,6 +193,11 @@ &:hover { border-color: @hover-border; + z-index: 1; + } + + &:not(:first-child):hover::before { + background-color: @hover-border !important; } &.ant-btn-disabled, @@ -201,6 +206,10 @@ &.ant-select-selection-disabled { color: fade(@dark-fg, 50%); border-color: @dark-border; + + &:hover::before { + background-color: @dark-border !important; + } } } @@ -215,6 +224,9 @@ // Radio button .ant-radio-button-wrapper:first-child { border-left: 1px solid @dark-border; + &:hover { + border-left: 1px solid @hover-border; + } } .ant-radio-button-wrapper-checked, @@ -223,6 +235,14 @@ color: @primary-border; } + .ant-radio-button-wrapper-checked .svg-gray-to-highlighted-blue { + // Calculated with https://codepen.io/sosuke/pen/Pjoqqp + // Input: background-color: #f1f1f1 for .pixel + // Prepended `brightness(50%) invert(39%)` to output + filter: brightness(50%) invert(39%) sepia(61%) saturate(2429%) hue-rotate(194deg) + brightness(105%) contrast(101%); + } + .ant-radio-button-wrapper:not(:first-child)::before { background-color: @dark-border; } diff --git a/public/images/lasso.svg b/public/images/lasso.svg new file mode 100644 index 00000000000..b446af8a7e2 --- /dev/null +++ b/public/images/lasso.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/public/images/new-cell.svg b/public/images/new-cell.svg new file mode 100644 index 00000000000..c1604451518 --- /dev/null +++ b/public/images/new-cell.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/public/images/overwrite-all.svg b/public/images/overwrite-all.svg new file mode 100644 index 00000000000..61d80e453bc --- /dev/null +++ b/public/images/overwrite-all.svg @@ -0,0 +1,10 @@ + + + + diff --git a/public/images/overwrite-empty.svg b/public/images/overwrite-empty.svg new file mode 100644 index 00000000000..3cf9b1611f5 --- /dev/null +++ b/public/images/overwrite-empty.svg @@ -0,0 +1,10 @@ + + + +