From 737ccfcfbaefede5e9655f51026b22ab4a07ae3a Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 29 Jun 2018 14:43:01 -0400 Subject: [PATCH] updated: siebar displays note details on hover (via svg) --- css/60_photos.css | 27 ++++ data/core.yaml | 13 ++ dist/locales/en.json | 14 ++ modules/behavior/TAH_select.js | 225 +++++++++++++++++++++++++++++++++ modules/behavior/hover.js | 12 +- modules/osm/note.js | 62 +++++++-- modules/services/notes.js | 32 +++-- modules/svg/notes.js | 25 +++- modules/ui/index.js | 1 + modules/ui/note_editor.js | 166 ++++++++++++++++++++++++ modules/ui/sidebar.js | 27 +++- 11 files changed, 571 insertions(+), 33 deletions(-) create mode 100644 modules/behavior/TAH_select.js create mode 100644 modules/ui/note_editor.js diff --git a/css/60_photos.css b/css/60_photos.css index ee44e7e9a4..1431791a14 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -118,10 +118,37 @@ pointer-events: none; } .layer-notes * { + pointer-events: visible; + cursor: pointer; color: #eebb00; } +/* TODO: possibly move this note detail .css to another file */ + +.comment-first { + background-color:#ddd; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} + +.comment { + background-color:#fff; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} + +.commentCreator { + color: #666; +} + +.commentText { + margin: 20px auto; +} + + /* Streetside Image Layer */ .layer-streetside-images { pointer-events: none; diff --git a/data/core.yaml b/data/core.yaml index 7da04749c6..26a4fc2782 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -609,6 +609,19 @@ en: title: "Photo Overlay (OpenStreetCam)" openstreetcam: view_on_openstreetcam: "View this image on OpenStreetCam" + note: + title: "Edit note" + unresolved: "Unresolved note #" + description: "Description" + creator: "Comment from" + anonymous: 'anonymous' + creatorOn: 'on' + commentTitle: 'Comments' + resolve: "Resolve" + comment: "Comment" + commentResolve: "Comment & Resolve" + save: "Save new note" + cancel: "Cancel" help: title: Help key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index e4140565ca..2aa17281b7 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -742,6 +742,20 @@ "openstreetcam": { "view_on_openstreetcam": "View this image on OpenStreetCam" }, + "note": { + "title": "Edit note", + "unresolved": "Unresolved note #", + "description": "Description", + "creator": "Comment from", + "anonymous": "anonymous", + "creatorOn": "on", + "commentTitle": "Comments", + "resolve": "Resolve", + "comment": "Comment", + "commentResolve": "Comment & Resolve", + "save": "Save new note", + "cancel": "Cancel" + }, "help": { "title": "Help", "key": "H", diff --git a/modules/behavior/TAH_select.js b/modules/behavior/TAH_select.js new file mode 100644 index 0000000000..c7ba6da580 --- /dev/null +++ b/modules/behavior/TAH_select.js @@ -0,0 +1,225 @@ +import _without from 'lodash-es/without'; + +import { + event as d3_event, + mouse as d3_mouse, + select as d3_select +} from 'd3-selection'; + +import { geoVecLength } from '../geo'; + +import { + modeBrowse, + modeSelect +} from '../modes'; + +import { + osmEntity, + osmNote +} from '../osm'; + + +export function behaviorSelect(context) { + var lastMouse = null; + var suppressMenu = true; + var tolerance = 4; + var p1 = null; + + + function point() { + return d3_mouse(context.container().node()); + } + + + function keydown() { + var e = d3_event; + if (e && e.shiftKey) { + context.surface() + .classed('behavior-multiselect', true); + } + + if (e && e.keyCode === 93) { // context menu + e.preventDefault(); + e.stopPropagation(); + } + } + + + function keyup() { + var e = d3_event; + if (!e || !e.shiftKey) { + context.surface() + .classed('behavior-multiselect', false); + } + + + if (e && e.keyCode === 93) { // context menu + e.preventDefault(); + e.stopPropagation(); + contextmenu(); + } + } + + + function mousedown() { + if (!p1) p1 = point(); + d3_select(window) + .on('mouseup.select', mouseup, true); + + var isShowAlways = +context.storage('edit-menu-show-always') === 1; + suppressMenu = !isShowAlways; + } + + + function mousemove() { + if (d3_event) lastMouse = d3_event; + } + + + function mouseup() { + click(); + } + + + function contextmenu() { + var e = d3_event; + e.preventDefault(); + e.stopPropagation(); + + if (!+e.clientX && !+e.clientY) { + if (lastMouse) { + e.sourceEvent = lastMouse; + } else { + return; + } + } + + if (!p1) p1 = point(); + suppressMenu = false; + click(); + } + + + function click() { + d3_select(window) + .on('mouseup.select', null, true); + + if (!p1) return; + var p2 = point(); + var dist = geoVecLength(p1, p2); + + p1 = null; + if (dist > tolerance) { + return; + } + + var isMultiselect = d3_event.shiftKey || d3_select('#surface .lasso').node(); + var isShowAlways = +context.storage('edit-menu-show-always') === 1; + var datum = d3_event.target.__data__ || (lastMouse && lastMouse.target.__data__); + var mode = context.mode(); + + var entity; + if (datum instanceof osmNote) { + entity = datum; + } else { + entity = datum && datum.properties && datum.properties.entity; + } + if (entity) datum = entity; + + if (datum && datum.type === 'midpoint') { + datum = datum.parents[0]; + } + + if (!(datum instanceof osmEntity) && !(datum instanceof osmNote)) { + // clicked nothing.. + if (!isMultiselect && mode.id !== 'browse') { + context.enter(modeBrowse(context)); + } + + } else { + // clicked an entity.. (or a notes) + var selectedIDs = context.selectedIDs(); + + if (!isMultiselect) { + if (selectedIDs.length > 1 && (!suppressMenu && !isShowAlways)) { + // multiple things already selected, just show the menu... + mode.suppressMenu(false).reselect(); + } else { + // select a single thing.. + context.enter(modeSelect(context, [datum.id]).suppressMenu(suppressMenu)); + } + + } else { + if (selectedIDs.indexOf(datum.id) !== -1) { + // clicked entity is already in the selectedIDs list.. + if (!suppressMenu && !isShowAlways) { + // don't deselect clicked entity, just show the menu. + mode.suppressMenu(false).reselect(); + } else { + // deselect clicked entity, then reenter select mode or return to browse mode.. + selectedIDs = _without(selectedIDs, datum.id); + context.enter(selectedIDs.length ? modeSelect(context, selectedIDs) : modeBrowse(context)); + } + } else { + // clicked entity is not in the selected list, add it.. + selectedIDs = selectedIDs.concat([datum.id]); + context.enter(modeSelect(context, selectedIDs).suppressMenu(suppressMenu)); + } + } + } + + // reset for next time.. + suppressMenu = true; + } + + + var behavior = function(selection) { + lastMouse = null; + suppressMenu = true; + p1 = null; + + d3_select(window) + .on('keydown.select', keydown) + .on('keyup.select', keyup) + .on('contextmenu.select-window', function() { + // Edge and IE really like to show the contextmenu on the + // menubar when user presses a keyboard menu button + // even after we've already preventdefaulted the key event. + var e = d3_event; + if (+e.clientX === 0 && +e.clientY === 0) { + d3_event.preventDefault(); + d3_event.stopPropagation(); + } + }); + + selection + .on('mousedown.select', mousedown) + .on('mousemove.select', mousemove) + .on('contextmenu.select', contextmenu); + + if (d3_event && d3_event.shiftKey) { + context.surface() + .classed('behavior-multiselect', true); + } + }; + + + behavior.off = function(selection) { + d3_select(window) + .on('keydown.select', null) + .on('keyup.select', null) + .on('contextmenu.select-window', null) + .on('mouseup.select', null, true); + + selection + .on('mousedown.select', null) + .on('mousemove.select', null) + .on('contextmenu.select', null); + + context.surface() + .classed('behavior-multiselect', false); + }; + + + return behavior; +} diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index e57687369d..1a1a9ee67d 100644 --- a/modules/behavior/hover.js +++ b/modules/behavior/hover.js @@ -6,7 +6,10 @@ import { } from 'd3-selection'; import { d3keybinding as d3_keybinding } from '../lib/d3.keybinding.js'; -import { osmEntity } from '../osm'; +import { + osmEntity, + osmNote +} from '../osm'; import { utilRebind } from '../util/rebind'; @@ -110,7 +113,12 @@ export function behaviorHover(context) { var entity; if (datum instanceof osmEntity) { entity = datum; - } else { + } + // TODO: TAH - reintroduce if we need a check for osmNote here + // else if (datum instanceof osmNote) { + // entity = datum; + // } + else { entity = datum && datum.properties && datum.properties.entity; } diff --git a/modules/osm/note.js b/modules/osm/note.js index 268992fcc3..c83ce950f1 100644 --- a/modules/osm/note.js +++ b/modules/osm/note.js @@ -3,32 +3,72 @@ import _extend from 'lodash-es/extend'; import { osmEntity } from './entity'; import { geoExtent } from '../geo'; +import { debug } from '../index'; -export function osmNote() { - if (!(this instanceof osmNote)) { - return (new osmNote()).initialize(arguments); - } else if (arguments.length) { - this.initialize(arguments); - } -} -osmEntity.note = osmNote; +export function osmNote() { + if (!(this instanceof osmNote)) return; -osmNote.prototype = Object.create(osmEntity.prototype); + this.initialize(arguments); + return this; +} _extend(osmNote.prototype, { type: 'note', + initialize: function(sources) { + for (var i = 0; i < sources.length; ++i) { + var source = sources[i]; + for (var prop in source) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + if (source[prop] === undefined) { + delete this[prop]; + } else { + this[prop] = source[prop]; + } + } + } + } + + if (!this.id && this.type) { + this.id = osmEntity.id(this.type); + } + if (!this.hasOwnProperty('visible')) { + this.visible = true; + } + + if (debug) { + Object.freeze(this); + Object.freeze(this.tags); + + if (this.loc) Object.freeze(this.loc); + if (this.nodes) Object.freeze(this.nodes); + if (this.members) Object.freeze(this.members); + } + + return this; + }, extent: function() { return new geoExtent(this.loc); }, - geometry: function(graph) { return graph.transient(this, 'geometry', function() { return graph.isPoi(this) ? 'point' : 'vertex'; }); + }, + + getID: function() { + return this.id; + }, + + getType: function() { + return this.type; + }, + + getComments: function() { + return this.comments; } -}); +}); \ No newline at end of file diff --git a/modules/services/notes.js b/modules/services/notes.js index ddcfa37520..5174dbb2ff 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -16,15 +16,16 @@ import { xml as d3_xml } from 'd3-request'; import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; import { geoExtent } from '../geo'; -import { - osmNote, -} from '../osm'; - import { utilRebind, utilIdleWorker } from '../util'; +import { + osmNote +} from '../osm'; +import { actionRestrictTurn } from '../actions'; + var urlroot = 'https://api.openstreetmap.org', _notesCache, dispatch = d3_dispatch('loadedNotes', 'loading'), @@ -111,7 +112,7 @@ function parseComments(comments) { } var parsers = { - note: function parseNote(obj) { + note: function parseNote(obj, uid) { var attrs = obj.attributes; var childNodes = obj.childNodes; var parsedNote = {}; @@ -130,6 +131,9 @@ var parsers = { } }); + parsedNote.id = uid; + parsedNote.type = 'note'; + return { minX: parsedNote.loc[0], minY: parsedNote.loc[1], @@ -151,17 +155,19 @@ function parse(xml, callback, options) { var parser = parsers[child.nodeName]; if (parser) { - // TODO: change how a note uid is parsed. Nodes & notes share 'n' + id combination var childNodes = child.childNodes; - var id; - var i; - for (i = 0; i < childNodes.length; i++) { - if (childNodes[i].nodeName === 'id') { id = childNodes[i].nodeName; } - } - if (options.cache && _entityCache[id]) { + + var uid; + _forEach(childNodes, function(node) { + if (node.nodeName === 'id') { + uid = child.nodeName + node.innerHTML; + } + }); + + if (options.cache && _entityCache[uid]) { return null; } - return parser(child); + return parser(child, uid); } } utilIdleWorker(children, parseChild, callback); diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 58e6f94d32..3d422f9872 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -3,12 +3,16 @@ import { select as d3_select } from 'd3-selection'; import { svgPointTransform } from './index'; import { services } from '../services'; +import { uiNoteEditor } from '../ui'; + export function svgNotes(projection, context, dispatch) { var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); var minZoom = 12; var layer = d3_select(null); var _notes; + var noteEditor = uiNoteEditor(context); + function init() { if (svgNotes.initialized) return; // run once svgNotes.enabled = false; @@ -57,6 +61,20 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } + function click(d) { + context.ui().sidebar.show(noteEditor, d); + } + + function mouseover(d) { + context.ui().sidebar.show(noteEditor, d); + } + + function mouseout(d) { + // TODO: check if the item was clicked. If so, it should remain on the sidebar. + // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js + context.ui().sidebar.hide(); + } + function update() { var service = getService(); var data = (service ? service.notes(projection) : []); @@ -70,12 +88,15 @@ export function svgNotes(projection, context, dispatch) { var notesEnter = notes.enter() .append('use') - .attr('class', 'note') + .attr('class', function(d) { return 'note ' + d.id; }) .attr('width', '24px') .attr('height', '24px') .attr('x', '-12px') .attr('y', '-12px') - .attr('xlink:href', '#fas-comment-alt'); + .attr('xlink:href', '#fas-comment-alt') + .on('click', click) + .on('mouseover', mouseover) + .on('mouseout', mouseout); notes .merge(notesEnter) diff --git a/modules/ui/index.js b/modules/ui/index.js index d908b70783..112b90d007 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -34,6 +34,7 @@ export { uiMapInMap } from './map_in_map'; export { uiModal } from './modal'; export { uiModes } from './modes'; export { uiNotice } from './notice'; +export { uiNoteEditor } from './note_editor'; export { uiPresetEditor } from './preset_editor'; export { uiPresetIcon } from './preset_icon'; export { uiPresetList } from './preset_list'; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js new file mode 100644 index 0000000000..821dfd02ca --- /dev/null +++ b/modules/ui/note_editor.js @@ -0,0 +1,166 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { uiFormFields } from './form_fields'; + + +import { uiField } from './field'; +import { utilRebind } from '../util'; +import { t } from '../util/locale'; + + +export function uiNoteEditor(context) { + var dispatch = d3_dispatch('change'); + var formFields = uiFormFields(context); + var _fieldsArr; + var _noteID; + + function noteEditor(selection, note) { + render(selection, note); + } + + function parseNoteUnresolved(selection, note) { + + var unresolved = selection.selectAll('.noteUnresolved') + .data(note, function(d) { return d.id; }) + .enter() + .append('h3') + .attr('class', 'noteUnresolved') + .text(function(d) { return String(t('note.unresolved') + ' ' + d.id); }); + + selection.merge(unresolved); + return selection; + } + + function parseNoteComments(selection, note) { + + function noteCreator(d) { + var userName = d.user ? d.user : t('note.anonymous'); + return String(t('note.creator') + ' ' + userName + ' ' + t('note.creatorOn') + ' ' + d.date); + } + + var comments = selection + .append('div') + .attr('class', 'comments'); + + var comment = comments.selectAll('.comment') + .data(note.comments, function(d) { return d.uid; }) + .enter() + .append('div') + .attr('class', 'comment'); + + // append the creator + comment + .append('p') + .attr('class', 'commentCreator') + .text(function(d) { return noteCreator(d); }); + + // append the comment + comment + .append('p') + .attr('class', 'commentText') + .text(function(d) { return d.text; }); + + comments.insert('h4', ':first-child') + .text(t('note.description')); + + // TODO: have a better check to highlight the first/author comment (e.g., check if `author: true`) + comments.select('div') + .attr('class', 'comment-first'); + + + selection.merge(comments); + return selection; + } + + function render(selection, note) { + + var exampleNote = { + close_url: 'example_close_url', + comment_url: 'example_comment_url', + comments: [ + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment1.</p>', + text: 'Test comment1', + uid: '111111', + user: 'User1', + user_url: 'example_user_url1' + }, + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment2.</p>', + text: 'Test comment2', + uid: '222222', + user: 'User2', + user_url: 'example_user_url2' + }, + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment3.</p>', + text: 'Test comment3', + uid: '333333', + user: 'User3', + user_url: 'example_user_url3' + } + ], + date_created: '2016-11-20 00:50:20 UTC', + id: 'note789148', + loc: [ + -120.0219036, + 34.4611879 + ], + status: 'open', + type: 'note', + url: 'https://api.openstreetmap.org/api/0.6/notes/789148', + visible: true + }; + + var currentNote = note ? [note] : [exampleNote]; + + var author = currentNote[0].comments[0]; + author.author = true; + + var header = selection.selectAll('.header') + .data([0]); + + header.enter() + .append('div') + .attr('class', 'header fillL') + .append('h3') + .text(t('note.title')); + + var body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + // Note Section + var noteSection = body.selectAll('.changeset-editor') + .data([0]); + + noteSection = noteSection.enter() + .append('div') + .attr('class', 'modal-section changeset-editor') + .merge(noteSection); + + noteSection = noteSection.call(parseNoteUnresolved, currentNote); + + noteSection = noteSection.call(parseNoteComments, currentNote[0]); + // TODO: revisit commit.js, changeset_editor.js to get warnings, fields array, button toggles, etc. + } + + noteEditor.noteID = function(_) { + if (!arguments.length) return _noteID; + if (_noteID === _) return noteEditor; + _noteID = _; + _fieldsArr = null; + return noteEditor; + }; + + return utilRebind(noteEditor, dispatch, 'on'); +} \ No newline at end of file diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 0062b70317..8782097d40 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -1,12 +1,26 @@ import _throttle from 'lodash-es/throttle'; import { uiFeatureList } from './feature_list'; import { uiInspector } from './inspector'; - +import { uiNoteEditor } from './note_editor'; export function uiSidebar(context) { - var inspector = uiInspector(context), - current; + var inspector = uiInspector(context), + noteEditor = uiNoteEditor(context), + current, + wasNote; + + function isNote(id) { + var isNote = (id && id.slice(0,4) === 'note') ? id.slice(0,4) : null; + // TODO: have a better check, perhaps see if the hover class is activated on a note + if (!isNote && wasNote) { + wasNote = false; + sidebar.hide(); + } else if (isNote) { + wasNote = true; + sidebar.show(noteEditor); + } + } function sidebar(selection) { var featureListWrap = selection @@ -21,6 +35,8 @@ export function uiSidebar(context) { function hover(id) { + // isNote(id); TODO: instantiate check if needed + if (!current && context.hasEntity(id)) { featureListWrap .classed('inspector-hidden', true); @@ -46,6 +62,7 @@ export function uiSidebar(context) { inspector .state('hide'); } + // } // TODO: - remove if note check logic is moved } @@ -82,7 +99,7 @@ export function uiSidebar(context) { }; - sidebar.show = function(component) { + sidebar.show = function(component, element) { featureListWrap .classed('inspector-hidden', true); inspectorWrap @@ -92,7 +109,7 @@ export function uiSidebar(context) { current = selection .append('div') .attr('class', 'sidebar-component') - .call(component); + .call(component, element); };