From 471354af4fb779f2ee36885b7e510cdf1ea73d16 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Tue, 8 Aug 2023 17:12:35 +0200 Subject: [PATCH] add list of loaded local photos --- css/60_photos.css | 95 +++++++++++++++++ css/80_app.css | 5 +- data/core.yaml | 6 +- modules/svg/local_photos.js | 56 +++++++---- modules/ui/sections/photo_overlays.js | 6 +- modules/ui/sections/raw_tag_editor.js | 6 +- modules/ui/settings/local_photos.js | 140 ++++++++++++++++++-------- 7 files changed, 244 insertions(+), 70 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index 4b655c853..4a5a247a9 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -471,3 +471,98 @@ label.streetside-hires { color: #fff; } } + +/* local georeferenced photos */ +.local-photos { + display: flex; +} +.local-photos > div { + width: 50%; +} +.local-photos > div:first-child { + margin-right: 20px; +} + +.preview-local-photos { + max-height: 40vh; + overflow-y: scroll; + overflow-x: auto; + /* workaround for something like "overflow-x: visible" + see https://stackoverflow.com/a/39554003 */ + margin-left: -100px; + padding-left: 100px; +} +.preview-local-photos::-webkit-scrollbar { + border-left: none; +} +.preview-local-photos li { + list-style: none; + display: flex; + justify-content: space-between; + height: 30px; +} +.preview-local-photos span.filename { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 30px; + padding-left: 8px; + border-bottom: 1px solid #ccc; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; +} +.preview-local-photos li:first-child span.filename { + border-top: 1px solid #ccc; + border-top-left-radius: 4px; +} +.preview-local-photos li:first-child button { + border-top: 1px solid #ccc; +} +.preview-local-photos li:first-child button.remove { + border-top-right-radius: 4px; +} +.preview-local-photos li:last-child span.filename { + border-bottom-left-radius: 4px; +} +.preview-local-photos li:last-child button.remove { + border-bottom-right-radius: 4px; +} +.preview-local-photos li.invalid span.filename { + color: #ccc; +} +/*.preview-local-photos li.invalid span.filename::before { + content: "! "; + color: red; +}*/ +.preview-local-photos li.invalid button.zoom-to-data { + display: none; +} +.preview-local-photos li button.no-geolocation { + display: none; +} +.preview-local-photos li.invalid button.no-geolocation { + display: block; +} +.preview-local-photos .placeholder div { + display: block; + height: 40px; + width: 40px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + background-image: url(img/loader-black.gif); + filter: invert(1); +} +.local-photos label.button { + background: #7092ff; + color: #fff; + font-weight: bold; + padding: 10px 25px; + text-align: center; + font-size: 12px; + display: inline-block; + border-radius: 4px; + cursor: pointer; +} \ No newline at end of file diff --git a/css/80_app.css b/css/80_app.css index faab82563..ca774fc5f 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -5650,7 +5650,7 @@ li.hide + li.version .badge .tooltip .popover-arrow { /* Scrollbars ----------------------------------------------------- */ ::-webkit-scrollbar { - height: 20px; + height: 10px; overflow: visible; width: 10px; border-left: 1px solid #DDD; @@ -5677,6 +5677,9 @@ li.hide + li.version .badge .tooltip .popover-arrow { background-color: rgba(0,0,0,.05); } } +body { + scrollbar-width: 10px; +} /* Intro walkthrough diff --git a/data/core.yaml b/data/core.yaml index f2e911a8c..4eb68fd64 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1446,9 +1446,13 @@ en: tooltip: Add georeferenced photos from local files tooltip_edit: Edit georeferenced photos header: Georeferenced Photos + zoom: Zoom to photos + zoom_single: Zoom to photo file: - instructions: "Choose georeferenced photos to be displayed. Supported types are:\n .jpg with exif location data" + instructions: "Choose georeferenced photos to be displayed. Supported types are .jpg and .png with exif location data" label: "Browse files" + no_geolocation: + tooltip: Image without geolocation cannot be located on the map note: note: Note title: Edit note diff --git a/modules/svg/local_photos.js b/modules/svg/local_photos.js index 974da61a4..7cca6aafe 100644 --- a/modules/svg/local_photos.js +++ b/modules/svg/local_photos.js @@ -1,7 +1,7 @@ +import { select as d3_select } from 'd3-selection'; import exifr from 'exifr'; import { utilDetect } from '../util/detect'; -import { select as d3_select } from 'd3-selection'; import { geoExtent } from '../geo'; import { isArray, isNumber } from 'lodash-es'; @@ -12,7 +12,8 @@ export function svgLocalPhotos(projection, context, dispatch) { var detected = utilDetect(); let layer = d3_select(null); var _fileList; - var _imageList = []; + var _photos = []; + var _idAutoinc = 0; function init() { if (_initialized) return; // run once @@ -45,7 +46,7 @@ export function svgLocalPhotos(projection, context, dispatch) { } // opens the image at bottom left - function click(d3_event, image) { + function click(d3_event, image, zoomTo) { // removes old div(s), if any closePhotoViewer(); @@ -71,7 +72,9 @@ export function svgLocalPhotos(projection, context, dispatch) { // centers the map with image location - context.map().centerEase(image.loc); + if (zoomTo) { + context.map().centerEase(image.loc); + } } @@ -127,7 +130,7 @@ export function svgLocalPhotos(projection, context, dispatch) { function drawPhotos(selection) { layer = selection.selectAll('.layer-local-photos') - .data(_fileList ? [0] : []); + .data(_photos ? [0] : []); layer.exit() .remove(); @@ -143,20 +146,18 @@ export function svgLocalPhotos(projection, context, dispatch) { layer = layerEnter .merge(layer); - // if (_imageList.length !== 0) { - // if (_fileList && _fileList.length !== 0) { - if (_imageList && _imageList.length !== 0) { - display_markers(_imageList); + if (_photos && _photos.length !== 0) { + display_markers(_photos); } } /** * Reads and parses files - * @param {Array} arrayFiles - Holds array of file - [file_1, file_2, ...] + * @param {Array} files - Holds array of file - [file_1, file_2, ...] */ - async function readmultifiles(arrayFiles) { - const filePromises = arrayFiles.map((file, i) => { + async function readmultifiles(files) { + const filePromises = files.map(file => { // Return a promise per file return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -167,8 +168,12 @@ export function svgLocalPhotos(projection, context, dispatch) { try { const response = await exifr.parse(file) .then(output => { - _imageList.push({ - id: i, + if (_photos.find(i => i.name === file.name && i.src === reader.result)) { + // skip if already loaded photos + return; + } + _photos.push({ + id: _idAutoinc++, name: file.name, src: reader.result, loc: [output.longitude, output.latitude] @@ -177,10 +182,12 @@ export function svgLocalPhotos(projection, context, dispatch) { // Resolve the promise with the response value resolve(response); } catch (err) { + console.error(err); // eslint-disable-line no-console reject(err); } }; reader.onerror = (error) => { + console.error(err); // eslint-disable-line no-console reject(error); }; @@ -188,15 +195,14 @@ export function svgLocalPhotos(projection, context, dispatch) { }); // Wait for all promises to be resolved - await Promise.all(filePromises); + await Promise.allSettled(filePromises); + _photos = _photos.sort((a, b) => a.id - b.id); dispatch.call('change'); } drawPhotos.setFile = function(fileList) { // read and parse asynchronously readmultifiles(Array.from(fileList)); - - dispatch.call('change'); return this; }; @@ -219,8 +225,20 @@ export function svgLocalPhotos(projection, context, dispatch) { return this; }; + drawPhotos.getPhotos = function() { + return _photos; + }; + + drawPhotos.removePhoto = function(id) { + _photos = _photos.filter(i => i.id !== id); + dispatch.call('change'); + return _photos; + }; + + drawPhotos.openPhoto = click; + drawPhotos.fitZoom = function() { - let extent = _imageList + let extent = _photos .map(image => image.loc) .filter(l => isArray(l) && isNumber(l[0]) && isNumber(l[1])) .map(l => geoExtent(l, l)) @@ -268,7 +286,7 @@ export function svgLocalPhotos(projection, context, dispatch) { }; drawPhotos.hasData = function() { - return isArray(_imageList) && _imageList.length > 0; + return isArray(_photos) && _photos.length > 0; }; diff --git a/modules/ui/sections/photo_overlays.js b/modules/ui/sections/photo_overlays.js index 3680f98be..04beb8bde 100644 --- a/modules/ui/sections/photo_overlays.js +++ b/modules/ui/sections/photo_overlays.js @@ -390,7 +390,7 @@ export function uiSectionPhotoOverlays(context) { .append('button') .attr('class', 'zoom-to-data') .call(uiTooltip() - .title(() => t.append('map_data.layers.custom.zoom')) + .title(() => t.append('local_photos.zoom')) .placement((localizer.textDirection() === 'rtl') ? 'right' : 'left') ) .on('click', function(d3_event) { @@ -427,9 +427,7 @@ export function uiSectionPhotoOverlays(context) { function localPhotosChanged(d) { var localPhotosLayer = layers.layer('local-photos'); - if (d && d.fileList) { - localPhotosLayer.fileList(d.fileList); - } + localPhotosLayer.fileList(d); } context.layers().on('change.uiSectionPhotoOverlays', section.reRender); diff --git a/modules/ui/sections/raw_tag_editor.js b/modules/ui/sections/raw_tag_editor.js index 43bd043d5..52d98b668 100644 --- a/modules/ui/sections/raw_tag_editor.js +++ b/modules/ui/sections/raw_tag_editor.js @@ -292,7 +292,11 @@ export function uiSectionRawTagEditor(id, context) { }); items.selectAll('button.remove') - .on(('PointerEvent' in window ? 'pointer' : 'mouse') + 'down', removeTag); // 'click' fires too late - #5878 + .on(('PointerEvent' in window ? 'pointer' : 'mouse') + 'down', // 'click' fires too late - #5878 + (d3_event, d) => { + if (d3_event.button !== 0) return; + removeTag(d3_event, d); + }); } diff --git a/modules/ui/settings/local_photos.js b/modules/ui/settings/local_photos.js index 2cf542135..a7de7a721 100644 --- a/modules/ui/settings/local_photos.js +++ b/modules/ui/settings/local_photos.js @@ -1,87 +1,139 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { select as d3_select } from 'd3-selection'; import { t } from '../../core/localizer'; import { uiConfirm } from '../confirm'; import { utilRebind } from '../../util'; +import { isArray, isNumber } from 'lodash-es'; +import { uiTooltip } from '../tooltip'; +import { svgIcon } from '../../svg'; export function uiSettingsLocalPhotos(context) { var dispatch = d3_dispatch('change'); + var photoLayer = context.layers().layer('local-photos'); + var modal; function render(selection) { - var dataLayer = context.layers().layer('local-photos'); - var _currSettings = { - fileList: (dataLayer && dataLayer.fileList()) || null - }; - - // var example = 'https://{switch:a,b,c}.tile.openstreetmap.org/{zoom}/{x}/{y}.png'; - var modal = uiConfirm(selection).okButton(); + modal = uiConfirm(selection).okButton(); modal - .classed('settings-modal settings-custom-data', true); + .classed('settings-modal settings-local-photos', true); modal.select('.modal-section.header') .append('h3') .call(t.append('local_photos.header')); + modal.select('.modal-section.message-text') + .append('div') + .classed('local-photos', true); - var textSection = modal.select('.modal-section.message-text'); + var instructionsSection = modal.select('.modal-section.message-text .local-photos') + .append('div') + .classed('instructions', true); - textSection - .append('pre') - .attr('class', 'instructions-local-photos') + instructionsSection + .append('p') + .classed('instructions-local-photos', true) .call(t.append('local_photos.file.instructions')); - textSection + instructionsSection .append('input') - .attr('class', 'field-file') + .classed('field-file', true) .attr('type', 'file') .attr('multiple', 'multiple') - // .attr('accept', '.gpx,.kml,.geojson,.json,application/gpx+xml,application/vnd.google-earth.kml+xml,application/geo+json,application/json') - .property('files', _currSettings.fileList) + .attr('accept', '.jpg,.jpeg,.png,image/png,image/jpeg') + .style('visibility', 'hidden') + .attr('id', 'local-photo-files') .on('change', function(d3_event) { var files = d3_event.target.files; if (files && files.length) { - _currSettings.fileList = files; - } else { - _currSettings.fileList = null; + previews + .select('ul') + .append('li') + .classed('placeholder', true) + .append('div'); + dispatch.call('change', this, files); } + d3_event.target.value = null; }); + instructionsSection + .append('label') + .attr('for', 'local-photo-files') + .classed('button', true) + .call(t.append('local_photos.file.label')); + const previews = modal.select('.modal-section.message-text .local-photos') + .append('div') + .append('div') + .classed('preview-local-photos', true) - // insert a cancel button - var buttonSection = modal.select('.modal-section.buttons'); + previews + .append('ul'); - buttonSection - .insert('button', '.ok-button') - .attr('class', 'button cancel-button secondary-action') - .call(t.append('confirm.cancel')); + updatePreviews(previews.select('ul')); + context.layers().on('change', () => updatePreviews(previews.select('ul'))); + } - buttonSection.select('.cancel-button') - .on('click.cancel', clickCancel); - - buttonSection.select('.ok-button') - .attr('disabled', isSaveDisabled) - .on('click.save', clickSave); - - - function isSaveDisabled() { - return null; + function updatePreviews(container) { + function locationUnavailable(d) { + return !(isArray(d.loc) && isNumber(d.loc[0]) && isNumber(d.loc[1])); } + container.selectAll('li.placeholder').remove(); - function clickCancel() { - this.blur(); - modal.close(); - } + let selection = container.selectAll('li') + .data(photoLayer.getPhotos() ?? [], d => d.id); + selection.exit() + .remove(); - function clickSave() { - this.blur(); - modal.close(); - dispatch.call('change', this, _currSettings); - } + const selectionEnter = selection.enter() + .append('li'); + + selectionEnter + .append('span') + .classed('filename', true); + selectionEnter + .append('button') + .classed('form-field-button zoom-to-data', true) + .attr('title', t('local_photos.zoom_single')) + .call(svgIcon('#iD-icon-framed-dot')); + selectionEnter + .append('button') + .classed('form-field-button no-geolocation', true) + .call(svgIcon('#iD-icon-alert')) + .call(uiTooltip() + .title(() => t.append('local_photos.no_geolocation.tooltip')) + .placement('left') + ); + selectionEnter + .append('button') + .classed('form-field-button remove', true) + .attr('title', t('icons.remove')) + .call(svgIcon('#iD-operation-delete')); + + selection = selection.merge(selectionEnter); + + selection + .classed('invalid', locationUnavailable); + selection.select('span.filename') + .text(d => d.name) + .attr('title', d => d.name); + selection.select('span.filename') + .on('click', (d3_event, d) => { + photoLayer.openPhoto(d3_event, d, false); + }); + selection.select('button.zoom-to-data') + .on('click', (d3_event, d) => { + photoLayer.openPhoto(d3_event, d, true); + }); + selection.select('button.remove') + .on('click', (d3_event, d) => { + photoLayer.removePhoto(d.id); + updatePreviews(container); + }); } return utilRebind(render, dispatch, 'on');