diff --git a/CHANGELOG.md b/CHANGELOG.md index ed436d41a5d..1b10bde78a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). ### Added +- Added the possibility to select multiple trees in skeleton tracings in the tree tab by using ctrl + left mouse. Deleting and moving trees will affect all selected trees. [#3457](https://github.com/scalableminds/webknossos/pull/3457) - Added the possibility to specify a recommended user configuration in a task type. The recommended configuration will be shown to users when they trace a task with a different task type and the configuration can be accepted or declined. [#3466](https://github.com/scalableminds/webknossos/pull/3466) - You can now create tracings on datasets of other organizations, provided you have access rights to the dataset (i.e. it is public). [#3533](https://github.com/scalableminds/webknossos/pull/3533) - Datasets imported through a datastore that is marked as 'scratch' will now show a construction-like header and error message to encourage moving the datasets to a permanent storage location. [#3500](https://github.com/scalableminds/webknossos/pull/3500) @@ -67,8 +68,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Removed support to watch additional dataset directories, no longer automatically creating symbolic links to the main directory. [#3416](https://github.com/scalableminds/webknossos/pull/3416) - ## [18.11.0](https://github.com/scalableminds/webknossos/releases/tag/18.11.0) - 2018-10-29 + [Commits](https://github.com/scalableminds/webknossos/compare/18.10.0...18.11.0) ### Highlights @@ -112,8 +113,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Fixed a bug which caused the save-button to never show success for volume tracings. [#3267](https://github.com/scalableminds/webknossos/pull/3267) - Fixed a rendering bug which caused data to turn black sometimes when moving around. [#3409](https://github.com/scalableminds/webknossos/pull/3409) - ## [18.10.0](https://github.com/scalableminds/webknossos/releases/tag/18.10.0) - 2018-09-22 + [Commits](https://github.com/scalableminds/webknossos/compare/18.09.0...18.10.0) ### Highlights @@ -133,7 +134,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Improved security by enabling http security headers. [#3084](https://github.com/scalableminds/webknossos/pull/3084) - Added the possibility to write markdown in the annotation description. [#3081](https://github.com/scalableminds/webknossos/pull/3081) - Added a view to restore any older version of a skeleton tracing. Access it through the dropdown next to the Save button. [#3194](https://github.com/scalableminds/webknossos/pull/3194) -![version-restore-highlight](https://user-images.githubusercontent.com/1702075/45428378-6842d380-b6a1-11e8-88c2-e4ffcd762cd5.png) + ![version-restore-highlight](https://user-images.githubusercontent.com/1702075/45428378-6842d380-b6a1-11e8-88c2-e4ffcd762cd5.png) - Added customizable layouting to the tracing view. [#3070](https://github.com/scalableminds/webknossos/pull/3070) - Added the brush size to the settings on the left in volume tracing. The size can now also be adjusted by using only the keyboard. [#3126](https://github.com/scalableminds/webknossos/pull/3126) - Added a user documentation for webKnossos [#3011](https://github.com/scalableminds/webknossos/pull/3011) diff --git a/app/assets/javascripts/admin/help/keyboardshortcut_view.js b/app/assets/javascripts/admin/help/keyboardshortcut_view.js index b0f5ad534ac..33bb91b4d1b 100644 --- a/app/assets/javascripts/admin/help/keyboardshortcut_view.js +++ b/app/assets/javascripts/admin/help/keyboardshortcut_view.js @@ -66,6 +66,10 @@ const KeyboardShortcutView = () => { keybinding: "Right Click Drag (3D View)", action: "Rotate 3D View", }, + { + keybinding: "Ctrl + Left Click", + action: "Select/Unselect a tree in the trees tab", + }, ]; const generalShortcuts = [ @@ -118,7 +122,7 @@ const KeyboardShortcutView = () => { action: "Increase/Decrease the Move Value", }, { - keybinding: "CTRL + Shift + F", + keybinding: "Ctrl + Shift + F", action: "Open Tree Search (if Tree List is visible)", }, ]; diff --git a/app/assets/javascripts/messages.js b/app/assets/javascripts/messages.js index 27fc288361e..80cd114b4df 100644 --- a/app/assets/javascripts/messages.js +++ b/app/assets/javascripts/messages.js @@ -88,6 +88,10 @@ In order to restore the current window, a reload is necessary.`, "tracing.delete_tree": "Do you really want to delete the whole tree?", "tracing.delete_tree_with_initial_node": "This tree contains the initial node. Do you really want to delete the whole tree?", + "tracing.delete_mulitple_trees": _.template( + "You have <%- countOfTrees %> trees selected, do you really want to delete all those trees?", + ), + "tracing.group_deletion_message": "Do you want to delete the selected group?", "tracing.merged": "Merging successfully done", "tracing.merged_with_redirect": "Merging successfully done. You will be redirected to the new annotation.", diff --git a/app/assets/javascripts/oxalis/model/actions/skeletontracing_actions.js b/app/assets/javascripts/oxalis/model/actions/skeletontracing_actions.js index 901a572cc78..8b6b61a83e8 100644 --- a/app/assets/javascripts/oxalis/model/actions/skeletontracing_actions.js +++ b/app/assets/javascripts/oxalis/model/actions/skeletontracing_actions.js @@ -80,7 +80,9 @@ type AddTreesAndGroupsAction = { }; type DeleteTreeAction = { type: "DELETE_TREE", treeId?: number, timestamp: number }; type SetActiveTreeAction = { type: "SET_ACTIVE_TREE", treeId: number }; +type DeselectActiveTreeAction = { type: "DESELECT_ACTIVE_TREE" }; type SetActiveGroupAction = { type: "SET_ACTIVE_GROUP", groupId: number }; +type DeselectActiveGroupAction = { type: "DESELECT_ACTIVE_GROUP" }; type MergeTreesAction = { type: "MERGE_TREES", sourceNodeId: number, targetNodeId: number }; type SetTreeNameAction = { type: "SET_TREE_NAME", name: ?string, treeId: ?number }; type SelectNextTreeAction = { type: "SELECT_NEXT_TREE", forward: ?boolean }; @@ -110,6 +112,7 @@ export type SkeletonTracingAction = | DeleteEdgeAction | SetActiveNodeAction | SetActiveGroupAction + | DeselectActiveGroupAction | SetNodeRadiusAction | CreateBranchPointAction | DeleteBranchPointAction @@ -118,6 +121,7 @@ export type SkeletonTracingAction = | AddTreesAndGroupsAction | DeleteTreeAction | SetActiveTreeAction + | DeselectActiveTreeAction | MergeTreesAction | SetTreeNameAction | SelectNextTreeAction @@ -313,11 +317,19 @@ export const setActiveTreeAction = (treeId: number): SetActiveTreeAction => ({ treeId, }); +export const deselectActiveTreeAction = (): DeselectActiveTreeAction => ({ + type: "DESELECT_ACTIVE_TREE", +}); + export const setActiveGroupAction = (groupId: number): SetActiveGroupAction => ({ type: "SET_ACTIVE_GROUP", groupId, }); +export const deselectActiveGroupAction = (): DeselectActiveGroupAction => ({ + type: "DESELECT_ACTIVE_GROUP", +}); + export const mergeTreesAction = (sourceNodeId: number, targetNodeId: number): MergeTreesAction => ({ type: "MERGE_TREES", sourceNodeId, @@ -418,18 +430,22 @@ export const deleteActiveNodeAsUserAction = ( ); }; +// Let the user confirm the deletion of the initial node (node with id 1) of a task +function confirmDeletingInitialNode(id) { + Modal.confirm({ + title: messages["tracing.delete_tree_with_initial_node"], + onOk: () => { + Store.dispatch(deleteTreeAction(id)); + }, + }); +} + export const deleteTreeAsUserAction = (treeId?: number): NoAction => { const state = Store.getState(); const skeletonTracing = enforceSkeletonTracing(state.tracing); getTree(skeletonTracing, treeId).map(tree => { if (state.task != null && tree.nodes.has(1)) { - // Let the user confirm the deletion of the initial node (node with id 1) of a task - Modal.confirm({ - title: messages["tracing.delete_tree_with_initial_node"], - onOk: () => { - Store.dispatch(deleteTreeAction(treeId)); - }, - }); + confirmDeletingInitialNode(treeId); } else if (state.userConfiguration.hideTreeRemovalWarning) { Store.dispatch(deleteTreeAction(treeId)); } else { @@ -442,3 +458,17 @@ export const deleteTreeAsUserAction = (treeId?: number): NoAction => { // if the user confirms return noAction(); }; + +export const deleteMultipleTreesAsUserAction = (treeIds: Array): NoAction => { + const state = Store.getState(); + const skeletonTracing = enforceSkeletonTracing(state.tracing); + treeIds.forEach(id => { + const tree = skeletonTracing.trees[id]; + if (state.task != null && tree.nodes.has(1)) { + confirmDeletingInitialNode(id); + } else { + Store.dispatch(deleteTreeAction(id)); + } + }); + return noAction(); +}; diff --git a/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer.js b/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer.js index 88c5c77b21a..ad750a066a6 100644 --- a/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer.js +++ b/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer.js @@ -27,6 +27,7 @@ import { toggleTreeGroupReducer, addTreesAndGroups, createTreeMapFromTreeArray, + removeMissingGroupsFromTrees, } from "oxalis/model/reducers/skeletontracing_reducer_helpers"; import { getSkeletonTracing, @@ -353,6 +354,17 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState .getOrElse(state); } + case "DESELECT_ACTIVE_TREE": { + return update(state, { + tracing: { + skeleton: { + activeNodeId: { $set: null }, + activeTreeId: { $set: null }, + }, + }, + }); + } + case "SET_ACTIVE_GROUP": { return update(state, { tracing: { @@ -365,6 +377,16 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState }); } + case "DESELECT_ACTIVE_GROUP": { + return update(state, { + tracing: { + skeleton: { + activeGroupId: { $set: null }, + }, + }, + }); + } + case "MERGE_TREES": { const { sourceNodeId, targetNodeId } = action; return mergeTrees(skeletonTracing, sourceNodeId, targetNodeId, restrictions) @@ -592,12 +614,15 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState } case "SET_TREE_GROUPS": { + const { treeGroups } = action; + const updatedTrees = removeMissingGroupsFromTrees(skeletonTracing, treeGroups); return update(state, { tracing: { skeleton: { treeGroups: { $set: action.treeGroups, }, + trees: { $merge: updatedTrees }, }, }, }); diff --git a/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.js b/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.js index 3f164504593..95cd36ce436 100644 --- a/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.js +++ b/app/assets/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.js @@ -798,3 +798,19 @@ export function createTreeMapFromTreeArray(trees: Array, +): TreeMap { + // Change the groupId of trees for groups that no longer exist + const groupIds = Array.from(mapGroups(treeGroups, group => group.groupId)); + const changedTrees = {}; + Object.keys(skeletonTracing.trees).forEach(treeId => { + const tree = skeletonTracing.trees[Number(treeId)]; + if (tree.groupId != null && !groupIds.includes(tree.groupId)) { + changedTrees[treeId] = { ...tree, groupId: null }; + } + }); + return changedTrees; +} diff --git a/app/assets/javascripts/oxalis/view/right-menu/delete_group_modal_view.js b/app/assets/javascripts/oxalis/view/right-menu/delete_group_modal_view.js new file mode 100644 index 00000000000..37af6e411e6 --- /dev/null +++ b/app/assets/javascripts/oxalis/view/right-menu/delete_group_modal_view.js @@ -0,0 +1,41 @@ +// @flow +import * as React from "react"; +import { Modal, Button } from "antd"; +import messages from "messages"; + +type Props = { + onJustDeleteGroup: () => void, + onDeleteGroupAndTrees: () => void, + onCancel: () => void, +}; + +export default function DeleteGroupModalView({ + onJustDeleteGroup, + onDeleteGroupAndTrees, + onCancel, +}: Props) { + return ( + + Cancel + , + , + , + ]} + > + Do you really want to remove the selected group? If you want to remove the group with all its + trees and subgroups recursively, select "Remove group recursively". If you want to + remove just the group and keep the subtrees and subgroups select "Remove group + only". + + ); +} diff --git a/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js b/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js index 0a51ce0d108..22abd25db7b 100644 --- a/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js +++ b/app/assets/javascripts/oxalis/view/right-menu/tree_hierarchy_view.js @@ -1,13 +1,12 @@ // @flow import { AutoSizer } from "react-virtualized"; -import { Dropdown, Menu, Icon, Checkbox } from "antd"; +import { Checkbox, Dropdown, Icon, Menu, Modal } from "antd"; import { connect } from "react-redux"; import * as React from "react"; import SortableTree from "react-sortable-tree"; import _ from "lodash"; import update from "immutability-helper"; - import { MISSING_GROUP_ID, TYPE_GROUP, @@ -41,13 +40,17 @@ type Props = { // eslint-disable-next-line react/no-unused-prop-types sortBy: string, trees: TreeMap, + selectedTrees: Array, onSetActiveTree: number => void, onSetActiveGroup: number => void, + onDeleteGroup: number => void, onToggleTree: number => void, onToggleAllTrees: () => void, onToggleTreeGroup: number => void, onUpdateTreeGroups: (Array) => void, onSetTreeGroup: (?number, number) => void, + onSelectTree: number => void, + deselectAllTrees: () => void, }; type State = { @@ -129,13 +132,35 @@ class TreeHierarchyView extends React.PureComponent { }; onSelectTree = evt => { - const treeId = evt.target.dataset.id; - this.props.onSetActiveTree(parseInt(treeId, 10)); + const treeId = parseInt(evt.target.dataset.id, 10); + if (evt.ctrlKey) { + this.props.onSelectTree(treeId); + } else { + this.props.deselectAllTrees(); + this.props.onSetActiveTree(treeId); + } }; onSelectGroup = evt => { - const groupId = evt.target.dataset.id; - this.props.onSetActiveGroup(parseInt(groupId, 10)); + const groupId = parseInt(evt.target.dataset.id, 10); + const numberOfSelectedTrees = this.props.selectedTrees.length; + const selectGroup = () => { + this.props.deselectAllTrees(); + this.props.onSetActiveGroup(groupId); + }; + if (numberOfSelectedTrees > 0) { + Modal.confirm({ + title: "Do you really want to select this group?", + content: `You have ${numberOfSelectedTrees} selected Trees. Do you really want to select this group? + This will deselect all selected trees.`, + onOk() { + selectGroup(); + }, + onCancel() {}, + }); + } else { + selectGroup(); + } }; onExpand = (params: { node: TreeNode, expanded: boolean }) => { @@ -154,10 +179,13 @@ class TreeHierarchyView extends React.PureComponent { }) => { const { nextParentNode, node, treeData } = params; if (node.type === TYPE_TREE) { - // A tree was dragged - update the group of the dragged tree - this.props.onSetTreeGroup( - nextParentNode.id === MISSING_GROUP_ID ? null : nextParentNode.id, - parseInt(node.id, 10), + const allTreesToMove = [...this.props.selectedTrees, node.id]; + // Sets group of all selected + dragged trees (and the moved tree) to the new parent group + allTreesToMove.forEach(treeId => + this.props.onSetTreeGroup( + nextParentNode.id === MISSING_GROUP_ID ? null : nextParentNode.id, + parseInt(treeId, 10), + ), ); } else { // A group was dragged - update the groupTree @@ -182,22 +210,7 @@ class TreeHierarchyView extends React.PureComponent { } deleteGroup(groupId: number) { - const newTreeGroups = _.cloneDeep(this.props.treeGroups); - const groupToTreesMap = createGroupToTreesMap(this.props.trees); - callDeep(newTreeGroups, groupId, (item, index, arr, parentGroupId) => { - // Remove group and move its group children to the parent group - arr.splice(index, 1); - arr.push(...item.children); - // Update the group of all its tree children to the parent group - const trees = groupToTreesMap[groupId] != null ? groupToTreesMap[groupId] : []; - for (const tree of trees) { - this.props.onSetTreeGroup( - parentGroupId === MISSING_GROUP_ID ? null : parentGroupId, - tree.treeId, - ); - } - }); - this.props.onUpdateTreeGroups(newTreeGroups); + this.props.onDeleteGroup(groupId); } handleDropdownClick = (params: { item: *, key: string }) => { @@ -210,6 +223,14 @@ class TreeHierarchyView extends React.PureComponent { } }; + getNodeStyleClassForBackground = id => { + const isTreeSelected = this.props.selectedTrees.includes(id); + if (isTreeSelected) { + return "selected-tree-node"; + } + return null; + }; + renderGroupActionsDropdown = (node: TreeNode) => { // The root group must not be removed or renamed const { id, name } = node; @@ -227,7 +248,6 @@ class TreeHierarchyView extends React.PureComponent { // Make sure the displayed name is not empty const displayableName = name.trim() || ""; - const nameAndDropdown = ( @@ -238,7 +258,6 @@ class TreeHierarchyView extends React.PureComponent { ); - return (
{ } else { const tree = this.props.trees[parseInt(node.id, 10)]; const rgbColorString = tree.color.map(c => Math.round(c * 255)).join(","); + // Defining background color of current node + const styleClass = this.getNodeStyleClassForBackground(node.id); nodeProps.title = ( -
+
{ render() { const { activeTreeId, activeGroupId } = this.props; return ( - + {({ height, width }) => (
- { - // this.renderCreateGroupModal() - } void, onCreateTree: () => void, onDeleteTree: () => void, + onDeleteMultipleTrees: (Array) => void, + onSetTreeGroup: (?number, number) => void, + onUpdateTreeGroups: (Array) => void, onChangeTreeName: string => void, annotation: Tracing, skeletonTracing?: SkeletonTracing, userConfiguration: UserConfiguration, onSetActiveTree: number => void, + onDeselectActiveTree: () => void, + onDeselectActiveGroup: () => void, showDropzoneModal: () => void, }; type State = { isUploading: boolean, isDownloading: boolean, + selectedTrees: Array, + groupToDelete: ?number, }; export async function importNmls(files: Array, createGroupForEachFile: boolean) { @@ -100,6 +119,8 @@ class TreesTabView extends React.PureComponent { state = { isUploading: false, isDownloading: false, + selectedTrees: [], + groupToDelete: null, }; handleChangeTreeName = evt => { @@ -114,8 +135,80 @@ class TreesTabView extends React.PureComponent { } }; - deleteTree = () => { - this.props.onDeleteTree(); + deleteGroup = (groupId: number, deleteRecursively = false) => { + if (!this.props.skeletonTracing) { + return; + } + const { treeGroups, trees } = this.props.skeletonTracing; + const newTreeGroups = _.cloneDeep(treeGroups); + const groupToTreesMap = createGroupToTreesMap(trees); + callDeep(newTreeGroups, groupId, (item, index, parentsChildren, parentGroupId) => { + const subtrees = groupToTreesMap[groupId] != null ? groupToTreesMap[groupId] : []; + // Remove group + parentsChildren.splice(index, 1); + if (!deleteRecursively) { + // Move all subgroups to the parent group + parentsChildren.push(...item.children); + // Update all subtrees + for (const tree of subtrees) { + this.props.onSetTreeGroup( + parentGroupId === MISSING_GROUP_ID ? null : parentGroupId, + tree.treeId, + ); + } + return; + } + // Removes all subtrees of the passed group recursively + const deleteGroupsRecursively = group => { + const currentSubtrees = + groupToTreesMap[group.groupId] != null ? groupToTreesMap[group.groupId] : []; + // Delete all trees of the current group + this.props.onDeleteMultipleTrees(currentSubtrees.map(tree => tree.treeId)); + // Also delete the trees of all subgroups + group.children.forEach(subgroup => deleteGroupsRecursively(subgroup)); + }; + deleteGroupsRecursively(item); + }); + + // Update the store and state after removing + this.props.onUpdateTreeGroups(newTreeGroups); + }; + + hideDeleteGroupsModal = () => { + this.setState({ groupToDelete: null }); + }; + + showDeleteGroupModal = (id: number) => { + this.setState({ groupToDelete: id }); + }; + + handleDelete = () => { + // If there exist selected trees, ask to remove them + const { selectedTrees } = this.state; + const numbOfSelectedTrees = selectedTrees.length; + if (numbOfSelectedTrees > 0) { + const deleteAllSelectedTrees = () => { + this.props.onDeleteMultipleTrees(selectedTrees); + this.setState({ selectedTrees: [] }); + }; + this.showModalConfimWarning( + "Delete all selected trees?", + messages["tracing.delete_mulitple_trees"]({ + countOfTrees: numbOfSelectedTrees, + }), + deleteAllSelectedTrees, + ); + } else { + // Just delete the active tree + this.props.onDeleteTree(); + } + // If there is an active group, ask the user whether to delete it or not + if (this.props.skeletonTracing) { + const { activeGroupId } = this.props.skeletonTracing; + if (activeGroupId != null) { + this.showDeleteGroupModal(activeGroupId); + } + } }; shuffleTreeColor = () => { @@ -155,6 +248,90 @@ class TreesTabView extends React.PureComponent { saveAs(blob, getNmlName(state)); }; + showModalConfimWarning(title: string, content: string, onConfirm: () => void) { + Modal.confirm({ + title, + content, + okText: "Ok", + cancelText: "No", + autoFocusButton: "cancel", + iconType: "warning", + onCancel: () => {}, + onOk: () => { + onConfirm(); + }, + }); + } + + onSelectTree = id => { + const tracing = this.props.skeletonTracing; + if (!tracing) { + return; + } + const { selectedTrees } = this.state; + // If the tree was already selected + if (selectedTrees.includes(id)) { + // If the tree is the second last -> set remaining tree to be the atcive tree + if (selectedTrees.length === 2) { + const lastSelectedTree = selectedTrees.find(treeId => treeId !== id); + if (lastSelectedTree != null) { + this.props.onSetActiveTree(lastSelectedTree); + } + this.deselectAllTrees(); + } else { + // Just deselect the tree + this.setState(prevState => ({ + selectedTrees: prevState.selectedTrees.filter(currentId => currentId !== id), + })); + } + } else { + const { activeTreeId } = tracing; + if (selectedTrees.length === 0) { + this.props.onDeselectActiveGroup(); + /* If there are no selected trees and no active tree: + Set selected tree to the active tree */ + if (activeTreeId == null) { + this.props.onSetActiveTree(id); + return; + } + } + // If the active node is selected, don't go into multi selection mode + if (activeTreeId === id) { + return; + } + if (selectedTrees.length === 0 && activeTreeId != null) { + // If this is the first selected tree -> also select the active tree + this.setState({ + selectedTrees: [id, activeTreeId], + }); + // Remove the current active tree + this.props.onDeselectActiveTree(); + } else { + // Just select this tree + this.setState(prevState => ({ + selectedTrees: [...prevState.selectedTrees, id], + })); + } + } + }; + + getAllSubtreeIdsOfGroup = (groupId: number): Array => { + if (!this.props.skeletonTracing) { + return []; + } + const { trees } = this.props.skeletonTracing; + const groupToTreesMap = createGroupToTreesMap(trees); + let subtreeIdsOfGroup = []; + if (groupToTreesMap[groupId]) { + subtreeIdsOfGroup = groupToTreesMap[groupId].map(node => node.treeId); + } + return subtreeIdsOfGroup; + }; + + deselectAllTrees = () => { + this.setState({ selectedTrees: [] }); + }; + getTreesComponents() { if (!this.props.skeletonTracing) { return null; @@ -168,6 +345,10 @@ class TreesTabView extends React.PureComponent { activeTreeId={this.props.skeletonTracing.activeTreeId} activeGroupId={this.props.skeletonTracing.activeGroupId} sortBy={orderAttribute} + selectedTrees={this.state.selectedTrees} + onSelectTree={this.onSelectTree} + deselectAllTrees={this.deselectAllTrees} + onDeleteGroup={this.showDeleteGroupModal} /> ); } @@ -177,6 +358,13 @@ class TreesTabView extends React.PureComponent { this.props.onSortTree(shouldSortTreesByName); }; + deleteGroupAndHideModal(groupToDelete: ?number, deleteSubtrees = false) { + this.hideDeleteGroupsModal(); + if (groupToDelete != null) { + this.deleteGroup(groupToDelete, deleteSubtrees); + } + } + getSettingsDropdown() { const activeMenuKey = this.props.userConfiguration.sortTreesByName ? "sortByName" @@ -217,6 +405,21 @@ class TreesTabView extends React.PureComponent { ); } + getSelectedTreesAlert = () => + this.state.selectedTrees.length > 0 ? ( + + {this.state.selectedTrees.length} Tree(s) selected.{" "} + + + } + /> + ) : null; + render() { const { skeletonTracing } = this.props; if (!skeletonTracing) { @@ -236,6 +439,7 @@ class TreesTabView extends React.PureComponent { } else if (this.state.isUploading) { title = "Importing NML"; } + const { groupToDelete } = this.state; return (
@@ -267,7 +471,7 @@ class TreesTabView extends React.PureComponent { Create - + Delete @@ -303,10 +507,21 @@ class TreesTabView extends React.PureComponent { -
    +
    {this.getSelectedTreesAlert()}
    {this.getTreesComponents()}
+ {groupToDelete !== null ? ( + { + this.deleteGroupAndHideModal(groupToDelete, false); + }} + onDeleteGroupAndTrees={() => { + this.deleteGroupAndHideModal(groupToDelete, true); + }} + /> + ) : null}
); } @@ -340,12 +555,27 @@ const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ onDeleteTree() { dispatch(deleteTreeAsUserAction()); }, + onDeleteMultipleTrees(treeIds) { + dispatch(deleteMultipleTreesAsUserAction(treeIds)); + }, + onSetTreeGroup(groupId, treeId) { + dispatch(setTreeGroupAction(groupId, treeId)); + }, + onUpdateTreeGroups(treeGroups) { + dispatch(setTreeGroupsAction(treeGroups)); + }, onChangeTreeName(name) { dispatch(setTreeNameAction(name)); }, onSetActiveTree(treeId) { dispatch(setActiveTreeAction(treeId)); }, + onDeselectActiveTree() { + dispatch(deselectActiveTreeAction()); + }, + onDeselectActiveGroup() { + dispatch(deselectActiveGroupAction()); + }, showDropzoneModal() { dispatch(setDropzoneModalVisibilityAction(true)); }, diff --git a/app/assets/stylesheets/trace_view/_right_menu.less b/app/assets/stylesheets/trace_view/_right_menu.less index 493babad037..3311a625b5a 100644 --- a/app/assets/stylesheets/trace_view/_right_menu.less +++ b/app/assets/stylesheets/trace_view/_right_menu.less @@ -27,6 +27,34 @@ .ant-input-group { margin: 10px 0px; } + + .tree-hierarchy-header { + color: rgba(24, 144, 255, 0.9); + margin-left: 16px; + margin-right: auto; + position: absolute; + right: 12px; + top: 12px; + z-index: 1; + } + + .clickable-text { + cursor: pointer; + } + + .selected-tree-node { + background-color: rgba(148, 247, 153, 0.9); + } + + .rst__rowLabel { + padding-right: 0px; + .rst__rowTitle div { + padding-right: 10px; + } + } + .rst__rowSearchMatch .rst__rowLabel { + background-color: rgb(135, 213, 255); + } } .info-tab-content { diff --git a/docs/tracing_ui.md b/docs/tracing_ui.md index 0a1d91abd8f..3f4b490e551 100644 --- a/docs/tracing_ui.md +++ b/docs/tracing_ui.md @@ -119,8 +119,8 @@ All operations and information regarding trees are organized under a tab called A typical skeleton annotation consists of one or more trees. Trees can be nested and organized in so-called `Tree Groups`. Tree groups can have a name and are used to structure and label your annotation even further. -Trees can be dragged and dropped between tree groups. -However over existing tree groups the bring up a little menu for creating new groups, renaming, and deletion. +Trees can be dragged and dropped between tree groups. This action can be applied to multiple trees by selecting them with Ctrl + Left Mouse. +Hover over existing tree groups to bring up a little menu for creating new groups and deletion. Renaming of a group can be done by selecting a group and then entering the new name into the input above the tree hierarchy structure view. ![Organize your skeleton annotation's trees to remember important structures for later reference](images/tracing_ui_trees.jpg)