diff --git a/.gitignore b/.gitignore index d9ddff83..e71649d4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ binaries/ coverage/ dist/ jsdoc/ +.vscode diff --git a/package-lock.json b/package-lock.json index ade5e7f1..4c5020db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "odin", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "odin", - "version": "2.7.0", + "version": "2.7.1", "hasInstallScript": true, "license": "AGPLv3", "dependencies": { @@ -41,6 +41,7 @@ "react-dom": "^18.2.0", "react-easy-sort": "^1.5.1", "react-fast-compare": "^3.2.0", + "react-tooltip": "^5.21.3", "reproject": "^1.2.7", "sanitize-filename": "^1.6.3", "subleveldown": "^6.0.1", @@ -2156,6 +2157,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", + "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "dependencies": { + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.2.tgz", + "integrity": "sha512-6ArmenS6qJEWmwzczWyhvrXRdI/rI78poBcW0h/456+onlabit+2G+QxHx5xTOX60NBJQXjsCLFbW2CmsXpUog==", + "dependencies": { + "@floating-ui/core": "^1.4.1", + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.2.tgz", + "integrity": "sha512-ou3elfqG/hZsbmF4bxeJhPHIf3G2pm0ujc39hYEZrfVqt7Vk/Zji6CXc3W0pmYM8BW1g40U+akTl9DKZhFhInQ==" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -4722,6 +4745,11 @@ "node": ">=8" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/clean-css": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", @@ -11977,6 +12005,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-tooltip": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.21.3.tgz", + "integrity": "sha512-z3Q+Uka4D6uYxfsssPqfx1W8vw7NIHyC2ZMq+NJkWg4EpUD3w7Fwz/o+dezyUQMCHL7nO/2sFbtWIrkyxktq2Q==", + "dependencies": { + "@floating-ui/dom": "^1.0.0", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, "node_modules/read-config-file": { "version": "6.3.2", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", diff --git a/package.json b/package.json index 0af8c515..8580c1c4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "odin", "productName": "ODINv2", - "version": "2.7.0", + "version": "2.7.1", "description": "Open Source Command and Control Information System (C2IS)", "main": "dist/main.js", "scripts": { @@ -88,6 +88,7 @@ "react-dom": "^18.2.0", "react-easy-sort": "^1.5.1", "react-fast-compare": "^3.2.0", + "react-tooltip": "^5.21.3", "reproject": "^1.2.7", "sanitize-filename": "^1.6.3", "subleveldown": "^6.0.1", diff --git a/src/renderer/components/DropdownMenu.js b/src/renderer/components/DropdownMenu.js index 13ed515f..e1f161b1 100644 --- a/src/renderer/components/DropdownMenu.js +++ b/src/renderer/components/DropdownMenu.js @@ -1,5 +1,6 @@ import React from 'react' import PropTypes from 'prop-types' +import { Tooltip } from 'react-tooltip' import * as mdi from '@mdi/js' import Icon from '@mdi/react' import uuid from 'uuid-random' @@ -8,14 +9,19 @@ import './DropdownMenu.css' export const DropdownMenu = props => { const [id] = React.useState(uuid()) + const [collapsed, setCollapsed] = React.useState(true) const handleClick = () => { document.getElementById(id).classList.toggle('show') + setCollapsed(current => !current) } const handleBlur = () => { // Let brief background flashing show on option selection (if any). - const hide = () => document.getElementById(id).classList.remove('show') + const hide = () => { + document.getElementById(id).classList.remove('show') + setCollapsed(true) + } setTimeout(hide, 200) } @@ -27,7 +33,8 @@ export const DropdownMenu = props => { } return ( -
+ <> +
+ { collapsed && } + ) } DropdownMenu.propTypes = { path: PropTypes.string.isRequired, - options: PropTypes.array.isRequired + options: PropTypes.array.isRequired, + toolTip: PropTypes.string } diff --git a/src/renderer/components/Toolbar.js b/src/renderer/components/Toolbar.js index 59e118ca..a1295ce1 100644 --- a/src/renderer/components/Toolbar.js +++ b/src/renderer/components/Toolbar.js @@ -54,7 +54,7 @@ export const Toolbar = () => { return (
- + { commands.map(([key, command]) => { return command === 'separator' @@ -62,18 +62,20 @@ export const Toolbar = () => { : }) } - +
diff --git a/src/renderer/components/ToolbarButtons.js b/src/renderer/components/ToolbarButtons.js index f61bee59..5df577cb 100644 --- a/src/renderer/components/ToolbarButtons.js +++ b/src/renderer/components/ToolbarButtons.js @@ -1,28 +1,35 @@ import React from 'react' import PropTypes from 'prop-types' +import { Tooltip } from 'react-tooltip' import Icon from '@mdi/react' import * as mdi from '@mdi/js' import './Toolbar.css' + export const SimpleButton = props => { const className = props.checked ? 'toolbar__button toolbar__button--checked' : 'toolbar__button' return ( + <> + + ) } SimpleButton.propTypes = { onClick: PropTypes.func.isRequired, path: PropTypes.string.isRequired, - checked: PropTypes.bool.isRequired + checked: PropTypes.bool.isRequired, + toolTip: PropTypes.string } @@ -38,13 +45,17 @@ export const CommandButton = props => { }, [command]) return ( + <> + + ) } diff --git a/src/renderer/components/properties/Coordinates.js b/src/renderer/components/properties/Coordinates.js index 9c844ee8..3c9e21fc 100644 --- a/src/renderer/components/properties/Coordinates.js +++ b/src/renderer/components/properties/Coordinates.js @@ -5,6 +5,7 @@ import * as mdi from '@mdi/js' import ColSpan2 from './ColSpan2' import FlexRow from './FlexRow' import textProperty from './textProperty' +import { Tooltip } from 'react-tooltip' import { useServices, useMemento } from '../hooks' const Button = props => { @@ -36,6 +37,7 @@ const TextProperty = props => { feature.geometry.coordinates = coordinates } catch (err) { /* TODO: handle parse error */ + console.error(err) } return feature @@ -50,9 +52,10 @@ const Coordinates = props => { -
-
+
) diff --git a/src/renderer/components/properties/TilePresetProperties.js b/src/renderer/components/properties/TilePresetProperties.js index c695259a..faecf58c 100644 --- a/src/renderer/components/properties/TilePresetProperties.js +++ b/src/renderer/components/properties/TilePresetProperties.js @@ -2,9 +2,10 @@ import React from 'react' import SortableList, { SortableItem } from 'react-easy-sort' import Icon from '@mdi/react' -import { mdiDrag, mdiEye, mdiEyeOff } from '@mdi/js' +import { mdiDrag, mdiEye, mdiEyeOff, mdiOpacity } from '@mdi/js' import { useServices, useList } from '../hooks' import Range from './Range' +import { Tooltip } from 'react-tooltip' import './TilePresetProperties.scss' @@ -46,19 +47,28 @@ const Layer = props => ( {/* react-easy-sort kills list style => add necessary margins. */}
- + {props.name} + + + +
{ : mdi.mdiPinOutline const renameButton = (capabilities || '').includes('RENAME') - ? emitter.emit('edit/begin', { id })}> - - + ? <> + emitter.emit('edit/begin', { id })}> + + + + : null return ( @@ -213,8 +217,9 @@ export const Card = React.forwardRef((props, ref) => { /> { renameButton } emitter.emit(pinned ? 'unpin' : 'pin', { id })}> - + +
{ children.body }
diff --git a/src/renderer/components/sidebar/FilterInput.js b/src/renderer/components/sidebar/FilterInput.js index 62ddd5e4..13f6f0b7 100644 --- a/src/renderer/components/sidebar/FilterInput.js +++ b/src/renderer/components/sidebar/FilterInput.js @@ -5,6 +5,7 @@ import { defaultSearch } from './state' import { matcher, stopPropagation } from '../events' import { cmdOrCtrl } from '../../platform' import { preventDefault } from 'ol/events/Event' +import { Tooltip } from 'react-tooltip' import './FilterInput.scss' @@ -69,7 +70,14 @@ export const FilterInput = props => { onChange={handleChange} onKeyDown={handleKeyDown} onClick={stopPropagation} + id='filter-input' /> + +
+ You can search for multiple phrases or #tags
+ and also combine them. +
+
) } diff --git a/src/renderer/components/sidebar/ScopeSwitcher.js b/src/renderer/components/sidebar/ScopeSwitcher.js index 52c76a18..10c56c2c 100644 --- a/src/renderer/components/sidebar/ScopeSwitcher.js +++ b/src/renderer/components/sidebar/ScopeSwitcher.js @@ -6,6 +6,7 @@ import { useMemento } from '../hooks' import { defaultSearch } from './state' import * as ID from '../../ids' import { IconTag } from './IconTag' +import { Tooltip } from 'react-tooltip' /** @@ -37,13 +38,17 @@ const ScopeSwitch = props => {
{props.name}
{props.label}
- : {props.label} + : <> + {props.label} + + } ScopeSwitch.propTypes = { name: PropTypes.string, label: PropTypes.string.isRequired, - scope: PropTypes.string.isRequired + scope: PropTypes.string.isRequired, + toolTip: PropTypes.string } @@ -76,11 +81,25 @@ export const ScopeSwitcher = props => { [`@${ID.MEASURE}`]: 'measure' } + const TOOLTIPS = { + '#pin': 'Manage pinned items', + [`@${ID.LAYER}`]: 'Manage existing layers', + [`@${ID.FEATURE}`]: 'Manage existing features', + [`@${ID.LINK}`]: 'Manage existing links', + [`@${ID.SYMBOL}`]: 'Create new features based on the symbol palette', + [`@${ID.MARKER}`]: 'Manage existing markers', + [`@${ID.BOOKMARK}`]: 'Manage existing bookmarks', + [`@${ID.PLACE}`]: 'Search for addresses based on OSM (online only)', + [`@${ID.TILE_SERVICE}`]: 'Manage existing tile services for maps', + [`@${ID.MEASURE}`]: 'Manage existing measurements' + } + const defaultSwitches = Object.entries(SCOPES).map(([scope, label]) => ) @@ -94,10 +113,13 @@ export const ScopeSwitcher = props => { ) const back = history.length > 1 - ? + + : null return ( diff --git a/src/renderer/components/sidebar/Sidebar.js b/src/renderer/components/sidebar/Sidebar.js index 5e8bd8ed..1c0888f5 100644 --- a/src/renderer/components/sidebar/Sidebar.js +++ b/src/renderer/components/sidebar/Sidebar.js @@ -11,6 +11,7 @@ import { ScopeSwitcher } from './ScopeSwitcher' import { MemoizedCard } from './Card' import { LazyList } from './LazyList' import { FilterInput } from './FilterInput' +import { Tooltip } from 'react-tooltip' import './Sidebar.scss' @@ -284,6 +285,27 @@ export const Sidebar = () => { focusIndex={state.focusIndex} renderEntry={renderEntry} /> + { + /* + Unfortunately we have elements that are addressed by both id and class selectors. In order + to avoid showing wrong tooltips we do not show a tt if the element has an id. + */ + if (activeAnchor?.id) return null + + const toggler = ['VISIBLE', 'HIDDEN', 'LOCKED', 'UNLOCKED'] + const highlighter = ['LAYER', 'FEATURE', 'MARKER', 'PLACE'] + const linker = ['LINK'] + + if (toggler.includes(activeAnchor?.innerText)) return 'Click to toggle value' + if (highlighter.includes(activeAnchor?.innerText)) return 'Click and hold to highlight' + if (linker.includes(activeAnchor?.innerText)) return 'Show assigned links' + return '' + }} /> + { + if (activeAnchor?.id) return null + return 'Show content' + }} /> + ) } diff --git a/src/renderer/model/commands/ClipboardCommands.js b/src/renderer/model/commands/ClipboardCommands.js index 0f63aafc..481d8843 100644 --- a/src/renderer/model/commands/ClipboardCommands.js +++ b/src/renderer/model/commands/ClipboardCommands.js @@ -1,9 +1,9 @@ export default services => { const { clipboard } = services return { - CLIPBOARD_CUT: { path: 'mdiContentCut', execute: () => clipboard.cut() }, - CLIPBOARD_COPY: { path: 'mdiContentCopy', execute: () => clipboard.copy() }, - CLIPBOARD_PASTE: { path: 'mdiContentPaste', execute: () => clipboard.paste() }, - CLIPBOARD_DELETE: { path: 'mdiTrashCanOutline', execute: () => clipboard.delete() } + CLIPBOARD_CUT: { path: 'mdiContentCut', execute: () => clipboard.cut(), toolTip: 'Cut' }, + CLIPBOARD_COPY: { path: 'mdiContentCopy', execute: () => clipboard.copy(), toolTip: 'Copy' }, + CLIPBOARD_PASTE: { path: 'mdiContentPaste', execute: () => clipboard.paste(), toolTip: 'Paste' }, + CLIPBOARD_DELETE: { path: 'mdiTrashCanOutline', execute: () => clipboard.delete(), toolTip: 'Delete' } } } diff --git a/src/renderer/model/commands/GlobalCommands.js b/src/renderer/model/commands/GlobalCommands.js index 0daf8a3d..99a25205 100644 --- a/src/renderer/model/commands/GlobalCommands.js +++ b/src/renderer/model/commands/GlobalCommands.js @@ -5,6 +5,7 @@ const Pin = function (services) { this.store = services.store this.selection = services.selection this.path = 'mdiPinOutline' + this.toolTip = 'Add this to the pinned items' this.selection.on('selection', () => this.emit('changed')) } diff --git a/src/renderer/model/commands/LayerCommands.js b/src/renderer/model/commands/LayerCommands.js index df13ee2e..cca2f0a0 100644 --- a/src/renderer/model/commands/LayerCommands.js +++ b/src/renderer/model/commands/LayerCommands.js @@ -11,6 +11,7 @@ const SetDefaultLayer = function (services) { this.selection = services.selection this.store = services.store this.path = 'mdiCreation' + this.toolTip = 'Make the selected layer the default layer' this.selection.on('selection', () => this.emit('changed')) } @@ -34,6 +35,7 @@ const SelectTilePreset = function (services) { this.preferencesStore = services.preferencesStore this.store = services.store this.tileLayerStore = services.tileLayerStore + this.toolTip = 'Change the visibility of the background maps' this.path = 'mdiMap' } @@ -47,6 +49,7 @@ const ExportLayer = function (services) { this.store = services.store this.clipboard = services.clipboard this.path = 'mdiExport' + this.toolTip = 'Export selected layer to the filesystem' this.selection.on('selection', () => this.emit('changed')) } diff --git a/src/renderer/model/commands/PrintCommands.js b/src/renderer/model/commands/PrintCommands.js index 5aa07dc6..c465a8f5 100644 --- a/src/renderer/model/commands/PrintCommands.js +++ b/src/renderer/model/commands/PrintCommands.js @@ -4,11 +4,13 @@ import EventEmitter from '../../../shared/emitter' const state = [ { event: 'TOOLBAR_SCOPE/PRINT', - path: 'mdiPrinterOutline' + path: 'mdiPrinterOutline', + toolTip: 'Print ...' }, { event: 'TOOLBAR_SCOPE/STANDARD', - path: 'mdiExitToApp' + path: 'mdiExitToApp', + toolTip: 'Return to the main application view' } ] @@ -30,13 +32,21 @@ PrintSwitchScopeCommand.prototype.enabled = function () { return true } const PrintCommand = function (services) { this.emitter = services.emitter this.path = 'mdiPrinter' + this.toolTip = 'Print this view now!' } PrintCommand.prototype.execute = function () { this.emitter.emit('PRINT') } +const buildScopeCommand = (services) => { + const command = new PrintSwitchScopeCommand(services) + Object.defineProperty(command, 'path', { get () { return state[this.currentState].path } }) + Object.defineProperty(command, 'toolTip', { get () { return state[this.currentState].toolTip } }) + return command +} + export default services => ({ - PRINT_SWITCH_SCOPE: Object.defineProperty(new PrintSwitchScopeCommand(services), 'path', { get () { return state[this.currentState].path } }), + PRINT_SWITCH_SCOPE: buildScopeCommand(services), PRINT_MAP: new PrintCommand(services) }) diff --git a/src/renderer/model/commands/UndoCommands.js b/src/renderer/model/commands/UndoCommands.js index 2367702b..24a5ad4a 100644 --- a/src/renderer/model/commands/UndoCommands.js +++ b/src/renderer/model/commands/UndoCommands.js @@ -4,6 +4,7 @@ import EventEmitter from '../../../shared/emitter' const Undo = function (services) { this.undo = services.undo this.path = 'mdiUndo' + this.toolTip = 'Undo' this.undo.on('changed', () => this.emit('changed')) } @@ -15,6 +16,7 @@ Undo.prototype.enabled = function () { return this.undo.canUndo() } const Redo = function (services) { this.undo = services.undo this.path = 'mdiRedo' + this.toolTip = 'Redo' this.undo.on('changed', () => this.emit('changed')) } diff --git a/src/renderer/store/options/marker.js b/src/renderer/store/options/marker.js index ca54e914..65e7f149 100644 --- a/src/renderer/store/options/marker.js +++ b/src/renderer/store/options/marker.js @@ -15,7 +15,7 @@ export default async function (id) { title: marker.name, description, tags: [ - 'SCOPE:MARKER:NONE', + 'SCOPE:MARKER', hidden ? 'SYSTEM:HIDDEN' : 'SYSTEM:VISIBLE', locked ? 'SYSTEM:LOCKED' : 'SYSTEM:UNLOCKED', ...((tags || [])).map(label => `USER:${label}:NONE`), diff --git a/src/renderer/store/options/place.js b/src/renderer/store/options/place.js index 8692100c..b84bf077 100644 --- a/src/renderer/store/options/place.js +++ b/src/renderer/store/options/place.js @@ -27,7 +27,7 @@ export default async function (id) { sort: `${distance}`, // should be string because of Intl.Collator.compare() description: `${place.description} - ${formattedDistance} km`, tags: [ - 'SCOPE:PLACE:NONE', + 'SCOPE:PLACE', ...systemTags, ...(userTags || []).map(label => `USER:${label}:NONE`), 'PLUS'