diff --git a/umap/static/umap/css/bar.css b/umap/static/umap/css/bar.css index cf71e2c1..4dc36f6d 100644 --- a/umap/static/umap/css/bar.css +++ b/umap/static/umap/css/bar.css @@ -14,11 +14,12 @@ background-color: inherit; } .leaflet-container .edit-save, -.leaflet-container .edit-cancel, +.leaflet-container .edit-undo, +.leaflet-container .edit-redo, .leaflet-container .edit-disable, .leaflet-container .connected-peers { - display: block; + display: inline-block; height: 32px; line-height: 30px; padding: 0 20px; @@ -39,7 +40,8 @@ color: var(--color-darkGray); } -.leaflet-container .edit-cancel:hover, +.leaflet-container .edit-undo:hover, +.leaflet-container .edit-redo:hover, .leaflet-container .edit-disable:hover { border: 0.5px solid rgba(153, 153, 153, 0.80); text-decoration: none; @@ -76,19 +78,13 @@ background: rgba(66, 236, 230, 0.10); } .leaflet-container .edit-save, -.leaflet-container .edit-cancel, -.leaflet-container .edit-disable, .umap-edit-enabled .edit-enable { display: none; } .umap-edit-enabled .edit-save, -.umap-edit-enabled .edit-disable, -.umap-edit-enabled.umap-is-dirty .edit-cancel { +.umap-edit-enabled .edit-disable { display: inline-block; } -.umap-is-dirty .edit-disable { - display: none; -} .umap-caption-bar { display: none; } @@ -115,8 +111,6 @@ .umap-right-edit-toolbox { display: flex; column-gap: 10px; -} -.umap-right-edit-toolbox { align-items: center; } @@ -135,17 +129,20 @@ text-indent: -9999px; } .umap-main-edit-toolbox .map-name { - display: inline-block; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; font-weight: bold; text-align: start; } +.truncate { + display: inline-flex; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.umap-main-edit-toolbox .username { + max-width: 100px; +} .umap-main-edit-toolbox .share-status { font-style: italic; - overflow: hidden; - text-overflow: ellipsis; } .map-name:after { content: '\00a0'; @@ -242,3 +239,14 @@ padding: 0; margin: 0; } +@media all and (max-width: 980px) { + .umap-main-edit-toolbox button span { + display: none; + } +} +@media all and (max-width: 770px) { + .umap-main-edit-toolbox .umap-help-link, + .umap-main-edit-toolbox .share-status { + display: none !important; + } +} diff --git a/umap/static/umap/css/icon.css b/umap/static/umap/css/icon.css index dfcffa72..30d5ce3b 100644 --- a/umap/static/umap/css/icon.css +++ b/umap/static/umap/css/icon.css @@ -167,11 +167,15 @@ html[dir="rtl"] .icon { .icon-profile { background-position: 0 calc(var(--tile) * 4); } +.icon-redo { + background-position: calc(var(--tile) * 3) calc(var(--tile) * 7); +} .icon-resize { background-position: calc(var(--tile) * 3) calc(var(--tile) * 6); } +.icon-undo, .icon-restore { - background-position: calc(var(--tile) * 5) calc(var(--tile) * 3); + background-position: calc(var(--tile) * 2) calc(var(--tile) * 7); } .expanded .icon-resize { background-position: calc(var(--tile) * 2) calc(var(--tile) * 6); diff --git a/umap/static/umap/img/16-white.svg b/umap/static/umap/img/16-white.svg index f61c1682..eae286ae 100644 --- a/umap/static/umap/img/16-white.svg +++ b/umap/static/umap/img/16-white.svg @@ -18,6 +18,9 @@ + + + @@ -67,9 +70,12 @@ - + + + + @@ -143,6 +149,10 @@ + + + + diff --git a/umap/static/umap/img/16.svg b/umap/static/umap/img/16.svg index e33dc876..eb32402f 100644 --- a/umap/static/umap/img/16.svg +++ b/umap/static/umap/img/16.svg @@ -1 +1 @@ -image/svg+xml   +image/svg+xml   diff --git a/umap/static/umap/img/source/16-white.svg b/umap/static/umap/img/source/16-white.svg index 7bb8097b..34efb72f 100644 --- a/umap/static/umap/img/source/16-white.svg +++ b/umap/static/umap/img/source/16-white.svg @@ -21,8 +21,11 @@ + + + - + @@ -78,9 +81,12 @@ - + + + + @@ -154,6 +160,10 @@ + + + + diff --git a/umap/static/umap/img/source/16.svg b/umap/static/umap/img/source/16.svg index 454d2fad..d264c313 100644 --- a/umap/static/umap/img/source/16.svg +++ b/umap/static/umap/img/source/16.svg @@ -1,4 +1,4 @@ -image/svg+xml   +image/svg+xml   diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index 8ccfe9ba..cd04dba3 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -91,6 +91,7 @@ class Feature { } set geometry(value) { + this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = value this.pushGeometry() } @@ -104,13 +105,15 @@ class Feature { } pullGeometry(sync = true) { + const oldGeometry = Utils.CopyJSON(this._geometry) this.fromLatLngs(this._getLatLngs()) if (sync) { - this.sync.update('geometry', this.geometry) + this.sync.update('geometry', this.geometry, oldGeometry) } } fromLatLngs(latlngs) { + this._geometry_bk = Utils.CopyJSON(this._geometry) this._geometry = this.convertLatLngs(latlngs) } @@ -145,8 +148,15 @@ class Feature { onCommit() { // When the layer is a remote layer, we don't want to sync the creation of the // points via the websocket, as the other peers will get them themselves. + const oldGeoJSON = this._just_married ? null : Utils.CopyJSON(this.toGeoJSON()) + this.pullGeometry(false) if (this.datalayer?.isRemoteLayer()) return - this.sync.upsert(this.toGeoJSON()) + if (this._just_married) { + this.sync.upsert(this.toGeoJSON(), null) + this._just_married = false + } else { + this.sync.update('geometry', this.geometry, this._geometry_bk) + } } isReadOnly() { diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 88ab9667..5d7e8eaf 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -16,7 +16,6 @@ import { Default as DefaultLayer } from '../rendering/layers/base.js' import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js' import { Cluster } from '../rendering/layers/cluster.js' import { Heat } from '../rendering/layers/heat.js' -import { ServerStored } from '../saving.js' import * as Schema from '../schema.js' import TableEditor from '../tableeditor.js' import * as Utils from '../utils.js' @@ -36,9 +35,8 @@ const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => { return acc }, {}) -export class DataLayer extends ServerStored { +export class DataLayer { constructor(umap, leafletMap, data = {}) { - super() this._umap = umap this.sync = umap.syncEngine.proxy(this) this._index = Array() @@ -49,7 +47,6 @@ export class DataLayer extends ServerStored { this._leafletMap = leafletMap this.parentPane = this._leafletMap.getPane('overlayPane') this.pane = this._leafletMap.createPane(`datalayer${stamp(this)}`, this.parentPane) - this.pane.dataset.id = stamp(this) // FIXME: should be on layer this.renderer = L.svg({ pane: this.pane }) this.defaultOptions = { @@ -66,6 +63,7 @@ export class DataLayer extends ServerStored { data.id = data.id || crypto.randomUUID() this.setOptions(data) + this.pane.dataset.id = this.id if (!Utils.isObject(this.options.remoteData)) { this.options.remoteData = {} @@ -114,7 +112,6 @@ export class DataLayer extends ServerStored { set isDeleted(status) { this._isDeleted = status - if (status) this.isDirty = status } get isDeleted() { @@ -269,13 +266,11 @@ export class DataLayer extends ServerStored { } clear() { - this.layer.clearLayers() - this._features = {} - this._index = Array() - if (this._geojson) { - this.backupData() - this._geojson = null + this.sync.startBatch() + for (const feature of Object.values(this._features)) { + feature.del() } + this.sync.commitBatch() this.dataChanged() } @@ -366,9 +361,8 @@ export class DataLayer extends ServerStored { } connectToMap() { - const id = stamp(this) - if (!this._umap.datalayers[id]) { - this._umap.datalayers[id] = this + if (!this._umap.datalayers[this.id]) { + this._umap.datalayers[this.id] = this } if (!this._umap.datalayersIndex.includes(this)) { this._umap.datalayersIndex.push(this) @@ -417,7 +411,10 @@ export class DataLayer extends ServerStored { removeFeature(feature, sync) { const id = stamp(feature) - if (sync !== false) feature.sync.delete() + if (sync !== false) { + const oldValue = feature.toGeoJSON() + feature.sync.delete(oldValue) + } this.hideFeature(feature) delete this._umap.featuresIndex[feature.getSlug()] feature.disconnectFromDataLayer(this) @@ -460,7 +457,10 @@ export class DataLayer extends ServerStored { try { // Do not fail if remote data is somehow invalid, // otherwise the layer becomes uneditable. - return this.makeFeatures(geojson, sync) + this.sync.startBatch() + const features = this.makeFeatures(geojson, sync) + this.sync.commitBatch() + return features } catch (err) { console.debug('Error with DataLayer', this.id) console.error(err) @@ -518,7 +518,7 @@ export class DataLayer extends ServerStored { } if (feature && !feature.isEmpty()) { this.addFeature(feature) - if (sync) feature.onCommit() + if (sync) feature.sync.upsert(feature.toGeoJSON(), null) return feature } } @@ -527,10 +527,6 @@ export class DataLayer extends ServerStored { return this._umap.formatter .parse(raw, format) .then((geojson) => this.addData(geojson)) - .then((data) => { - if (data?.length) this.isDirty = true - return data - }) .catch((error) => { console.debug(error) Alert.error(translate('Import failed: invalid data')) @@ -596,17 +592,17 @@ export class DataLayer extends ServerStored { } del(sync = true) { + const oldValue = Utils.CopyJSON(this.umapGeoJSON()) this.erase() if (sync) { this.isDeleted = true - this.sync.delete() + this.sync.delete(oldValue) } } empty() { if (this.isRemoteLayer()) return this.clear() - this.isDirty = true } clone() { @@ -630,25 +626,6 @@ export class DataLayer extends ServerStored { this.clear() } - reset() { - if (!this.createdOnServer) { - this.erase() - return - } - - this.resetOptions() - this.parentPane.appendChild(this.pane) - if (this._leaflet_events_bk && !this._leaflet_events) { - this._leaflet_events = this._leaflet_events_bk - } - this.clear() - this.hide() - if (this.isRemoteLayer()) this.fetchRemoteData() - else if (this._geojson_bk) this.fromGeoJSON(this._geojson_bk) - this.show() - this.isDirty = false - } - redraw() { if (!this.isVisible()) return this.eachFeature((feature) => feature.redraw()) @@ -940,11 +917,14 @@ export class DataLayer extends ServerStored { ) if (!error) { if (geojson._storage) geojson._umap_options = geojson._storage // Retrocompat. - if (geojson._umap_options) this.setOptions(geojson._umap_options) + if (geojson._umap_options) { + const oldOptions = Utils.CopyJSON(this.options) + this.setOptions(geojson._umap_options) + this.sync.update('options', this.options, oldOptions) + } this.empty() if (this.isRemoteLayer()) this.fetchRemoteData() else this.addData(geojson) - this.isDirty = true } }) } @@ -1098,7 +1078,11 @@ export class DataLayer extends ServerStored { setReferenceVersion({ response, sync }) { this._referenceVersion = response.headers.get('X-Datalayer-Version') - if (sync) this.sync.update('_referenceVersion', this._referenceVersion) + if (sync) { + this.sync.update('_referenceVersion', this._referenceVersion, null, { + undo: false, + }) + } } async save() { @@ -1127,6 +1111,10 @@ export class DataLayer extends ServerStored { } async _trySave(url, headers, formData) { + if (this._forceSave) { + headers = {} + this._forceSave = false + } const [data, response, error] = await this._umap.server.post(url, headers, formData) if (error) { if (response && response.status === 412) { @@ -1136,15 +1124,8 @@ export class DataLayer extends ServerStored { 'This situation is tricky, you have to choose carefully which version is pertinent.' ), async () => { - // Save again this layer - const status = await this._trySave(url, {}, formData) - if (status) { - this.isDirty = false - - // Call the main save, in case something else needs to be saved - // as the conflict stopped the saving flow - await this._umap.saveAll() - } + this._forceSave = true + await this._umap.saveAll() } ) } @@ -1179,7 +1160,7 @@ export class DataLayer extends ServerStored { } commitDelete() { - delete this._umap.datalayers[stamp(this)] + delete this._umap.datalayers[this.id] } getName() { diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index da896af1..e41a7e3d 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -135,7 +135,13 @@ export default class Facets { for (const [property, { label, type }] of parsed) { dumped.push([property, label, type].filter(Boolean).join('|')) } - return dumped.join(',') + const oldValue = this._umap.properties.facetKey + this._umap.properties.facetKey = dumped.join(',') + this._umap.sync.update( + 'properties.facetKey', + this._umap.properties.facetKey, + oldValue + ) } has(property) { @@ -146,15 +152,13 @@ export default class Facets { const defined = this.getDefined() if (!defined.has(property)) { defined.set(property, { label, type }) - this._umap.properties.facetKey = this.dumps(defined) - this._umap.isDirty = true + this.dumps(defined) } } remove(property) { const defined = this.getDefined() defined.delete(property) - this._umap.properties.facetKey = this.dumps(defined) - this._umap.isDirty = true + this.dumps(defined) } } diff --git a/umap/static/umap/js/modules/form/builder.js b/umap/static/umap/js/modules/form/builder.js index b505c817..88698d39 100644 --- a/umap/static/umap/js/modules/form/builder.js +++ b/umap/static/umap/js/modules/form/builder.js @@ -70,21 +70,7 @@ export class Form extends Utils.WithEvents { } setter(field, value) { - const path = field.split('.') - let obj = this.obj - let what - for (let i = 0, l = path.length; i < l; i++) { - what = path[i] - if (what === path[l - 1]) { - if (typeof value === 'undefined') { - delete obj[what] - } else { - obj[what] = value - } - } else { - obj = obj[what] - } - } + Utils.setObjectValue(this.obj, field, value) } restoreField(field) { @@ -190,13 +176,17 @@ export class MutatingForm extends Form { } setter(field, value) { - super.setter(field, value) - this.obj.isDirty = true + const oldValue = this.getter(field) + if ('setter' in this.obj) { + this.obj.setter(field, value) + } else { + super.setter(field, value) + } if ('render' in this.obj) { this.obj.render([field], this) } if ('sync' in this.obj) { - this.obj.sync.update(field, value) + this.obj.sync.update(field, value, oldValue) } } diff --git a/umap/static/umap/js/modules/form/fields.js b/umap/static/umap/js/modules/form/fields.js index 71abe87d..5a8be8d6 100644 --- a/umap/static/umap/js/modules/form/fields.js +++ b/umap/static/umap/js/modules/form/fields.js @@ -541,14 +541,14 @@ Fields.DataLayerSwitcher = class extends Fields.Select { !datalayer.isDataReadOnly() && datalayer.isBrowsable() ) { - options.push([L.stamp(datalayer), datalayer.getName()]) + options.push([datalayer.id, datalayer.getName()]) } }) return options } toHTML() { - return L.stamp(this.obj.datalayer) + return this.obj.datalayer.id } toJS() { diff --git a/umap/static/umap/js/modules/importer.js b/umap/static/umap/js/modules/importer.js index 83e09812..f2738f08 100644 --- a/umap/static/umap/js/modules/importer.js +++ b/umap/static/umap/js/modules/importer.js @@ -249,7 +249,7 @@ export default class Importer extends Utils.WithTemplate { tagName: 'option', parent: layerSelect, textContent: datalayer.options.name, - value: L.stamp(datalayer), + value: datalayer.id, }) } }) diff --git a/umap/static/umap/js/modules/permissions.js b/umap/static/umap/js/modules/permissions.js index ffaab4fa..04b0b0bd 100644 --- a/umap/static/umap/js/modules/permissions.js +++ b/umap/static/umap/js/modules/permissions.js @@ -2,17 +2,15 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js' import { uMapAlert as Alert } from '../components/alerts/alert.js' import { MutatingForm } from './form/builder.js' import { translate } from './i18n.js' -import { ServerStored } from './saving.js' import * as Utils from './utils.js' // Dedicated object so we can deal with a separate dirty status, and thus // call the endpoint only when needed, saving one call at each save. -export class MapPermissions extends ServerStored { +export class MapPermissions { constructor(umap) { - super() this.setProperties(umap.properties.permissions) this._umap = umap - this._isDirty = false + this.sync = umap.syncEngine.proxy(this) } setProperties(properties) { @@ -28,6 +26,13 @@ export class MapPermissions extends ServerStored { ) } + getSyncMetadata() { + return { + subject: 'mappermissions', + metadata: {}, + } + } + render() { this._umap.render(['properties.permissions']) } @@ -188,7 +193,6 @@ export class MapPermissions extends ServerStored { } async save() { - if (!this.isDirty) return const formData = new FormData() if (!this.isAnonymousMap() && this.properties.editors) { const editors = this.properties.editors.map((u) => u.id) @@ -247,9 +251,8 @@ export class MapPermissions extends ServerStored { } } -export class DataLayerPermissions extends ServerStored { +export class DataLayerPermissions { constructor(umap, datalayer) { - super() this._umap = umap this.properties = Object.assign( { @@ -259,6 +262,14 @@ export class DataLayerPermissions extends ServerStored { ) this.datalayer = datalayer + this.sync = umap.syncEngine.proxy(this) + } + + getSyncMetadata() { + return { + subject: 'datalayerpermissions', + metadata: { id: this.datalayer.id }, + } } edit(container) { @@ -289,7 +300,6 @@ export class DataLayerPermissions extends ServerStored { } async save() { - if (!this.isDirty) return const formData = new FormData() formData.append('edit_status', this.properties.edit_status) const [data, response, error] = await this._umap.server.post( diff --git a/umap/static/umap/js/modules/rendering/layers/classified.js b/umap/static/umap/js/modules/rendering/layers/classified.js index a9b2cfb6..65b234bf 100644 --- a/umap/static/umap/js/modules/rendering/layers/classified.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -117,7 +117,7 @@ export const Choropleth = FeatureGroup.extend({ }, _getValue: function (feature) { - const key = this.datalayer.options.choropleth.property || 'value' + const key = this.datalayer.options.choropleth?.property || 'value' const value = +feature.properties[key] if (!Number.isNaN(value)) return value }, @@ -130,12 +130,12 @@ export const Choropleth = FeatureGroup.extend({ this.options.colors = [] return } - const mode = this.datalayer.options.choropleth.mode - let classes = +this.datalayer.options.choropleth.classes || 5 + const mode = this.datalayer.options.choropleth?.mode + let classes = +this.datalayer.options.choropleth?.classes || 5 let breaks classes = Math.min(classes, values.length) if (mode === 'manual') { - const manualBreaks = this.datalayer.options.choropleth.breaks + const manualBreaks = this.datalayer.options.choropleth?.breaks if (manualBreaks) { breaks = manualBreaks .split(',') diff --git a/umap/static/umap/js/modules/rendering/ui.js b/umap/static/umap/js/modules/rendering/ui.js index 1755c046..6f18bbda 100644 --- a/umap/static/umap/js/modules/rendering/ui.js +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -97,7 +97,6 @@ const FeatureMixin = { }, onCommit: function () { - this.feature.pullGeometry(false) this.feature.onCommit() }, } @@ -112,7 +111,7 @@ const PointMixin = { this.on('dragend', (event) => { this.isDirty = true this.feature.edit(event) - this.feature.pullGeometry(false) + // this.feature.pullGeometry(false) }) if (!this.feature.isReadOnly()) this.on('mouseover', this._enableDragging) this.on('mouseout', this._onMouseOut) @@ -303,13 +302,13 @@ const PathMixin = { this._container = null FeatureMixin.onAdd.call(this, map) this.setStyle() - if (this.editing?.enabled()) this.editing.addHooks() + if (this.editor?.enabled()) this.editor.addHooks() this.resetTooltip() this._path.dataset.feature = this.feature.id }, onRemove: function (map) { - if (this.editing?.enabled()) this.editing.removeHooks() + if (this.editor?.enabled()) this.editor.removeHooks() FeatureMixin.onRemove.call(this, map) }, @@ -362,6 +361,13 @@ const PathMixin = { isOnScreen: function (bounds) { return bounds.overlaps(this.getBounds()) }, + + _setLatLngs: function (latlngs) { + this.parentClass.prototype._setLatLngs.call(this, latlngs) + if (this.editor?.enabled()) { + this.editor.reset() + } + }, } export const LeafletPolyline = Polyline.extend({ diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index a6c39ad1..4ecf3724 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -17,20 +17,10 @@ class Rule { this.parse() } - get isDirty() { - return this._isDirty - } - - set isDirty(status) { - this._isDirty = status - if (status) this._umap.isDirty = status - } - constructor(umap, condition = '', options = {}) { // TODO make this public properties when browser coverage is ok // cf https://caniuse.com/?search=public%20class%20field this._condition = null - this._isDirty = false this.OPERATORS = [ ['>', this.gt], ['<', this.lt], @@ -190,17 +180,25 @@ class Rule { _delete() { this._umap.rules.rules = this._umap.rules.rules.filter((rule) => rule !== this) + this._umap.rules.commit() + } + + setter(key, value) { + const oldRules = Utils.CopyJSON(this._umap.properties.rules || {}) + Utils.setObjectValue(this, key, value) + this._umap.rules.commit() + this._umap.sync.update('properties.rules', this._umap.properties.rules, oldRules) } } export default class Rules { constructor(umap) { this._umap = umap - this.rules = [] this.load() } load() { + this.rules = [] if (!this._umap.properties.rules?.length) return for (const { condition, options } of this._umap.properties.rules) { if (!condition) continue @@ -222,8 +220,8 @@ export default class Rules { else if (finalIndex > initialIndex) newIdx = referenceIdx else newIdx = referenceIdx + 1 this.rules.splice(newIdx, 0, moved) - moved.isDirty = true this._umap.render(['rules']) + this.commit() } edit(container) { @@ -242,7 +240,6 @@ export default class Rules { addRule() { const rule = new Rule(this._umap) - rule.isDirty = true this.rules.push(rule) rule.edit(map) } diff --git a/umap/static/umap/js/modules/saving.js b/umap/static/umap/js/modules/saving.js deleted file mode 100644 index 501fe19a..00000000 --- a/umap/static/umap/js/modules/saving.js +++ /dev/null @@ -1,52 +0,0 @@ -const _queue = new Set() - -export let isDirty = false - -export async function save() { - for (const obj of _queue) { - const ok = await obj.save() - if (!ok) break - remove(obj) - } -} - -export function clear() { - _queue.clear() - onUpdate() -} - -function add(obj) { - _queue.add(obj) - onUpdate() -} - -function remove(obj) { - _queue.delete(obj) - onUpdate() -} - -function has(obj) { - return _queue.has(obj) -} - -function onUpdate() { - isDirty = Boolean(_queue.size) - document.body.classList.toggle('umap-is-dirty', isDirty) -} - -export class ServerStored { - set isDirty(status) { - if (status) { - add(this) - } else { - remove(this) - } - this.onDirty(status) - } - - get isDirty() { - return has(this) - } - - onDirty(status) {} -} diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index d2142f8c..24a44d0b 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -44,6 +44,10 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, + center: { + type: Object, + impacts: [], // default center, doesn't need any update of the map + }, color: { type: String, impacts: ['data'], @@ -118,6 +122,9 @@ export const SCHEMA = { default: false, label: translate('Animated transitions'), }, + edit_status: { + type: Number, + }, editinosmControl: { type: Boolean, impacts: ['ui'], @@ -125,6 +132,9 @@ export const SCHEMA = { label: translate('Display the control to open OpenStreetMap editor'), default: null, }, + editors: { + type: Array, + }, embedControl: { type: Boolean, impacts: ['ui'], @@ -362,6 +372,9 @@ export const SCHEMA = { type: Object, impacts: ['background'], }, + owner: { + type: Object, + }, permanentCredit: { type: 'Text', impacts: ['ui'], @@ -436,6 +449,9 @@ export const SCHEMA = { label: translate('Display the search control'), default: true, }, + share_status: { + type: Number, + }, shortCredit: { type: String, impacts: ['ui'], @@ -500,6 +516,9 @@ export const SCHEMA = { helpEntries: ['sync'], default: false, }, + team: { + type: Object, + }, tilelayer: { type: Object, impacts: ['background'], @@ -566,7 +585,6 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, - _referenceVersion: { type: Number, impacts: ['data'], diff --git a/umap/static/umap/js/modules/sync/engine.js b/umap/static/umap/js/modules/sync/engine.js index 63af120e..b6f0d151 100644 --- a/umap/static/umap/js/modules/sync/engine.js +++ b/umap/static/umap/js/modules/sync/engine.js @@ -1,7 +1,13 @@ -import * as SaveManager from '../saving.js' import * as Utils from '../utils.js' import { HybridLogicalClock } from './hlc.js' -import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' +import { UndoManager } from './undo.js' +import { + DataLayerUpdater, + FeatureUpdater, + MapUpdater, + MapPermissionsUpdater, + DataLayerPermissionsUpdater, +} from './updaters.js' import { WebSocketTransport } from './websocket.js' // Start reconnecting after 2 seconds, then double the delay each time @@ -55,6 +61,8 @@ export class SyncEngine { map: new MapUpdater(umap), feature: new FeatureUpdater(umap), datalayer: new DataLayerUpdater(umap), + mappermissions: new MapPermissionsUpdater(umap), + datalayerpermissions: new DataLayerPermissionsUpdater(umap), } this.transport = undefined this._operations = new Operations() @@ -64,6 +72,7 @@ export class SyncEngine { this.websocketConnected = false this.closeRequested = false this.peerId = Utils.generateId() + this._undoManager = new UndoManager(umap, this.updaters, this) } get isOpen() { @@ -122,16 +131,107 @@ export class SyncEngine { await this.authenticate() }, this._reconnectDelay) } - upsert(subject, metadata, value) { - this._send({ verb: 'upsert', subject, metadata, value }) + + startBatch() { + this._batch = [] } - update(subject, metadata, key, value) { - this._send({ verb: 'update', subject, metadata, key, value }) + commitBatch(subject, metadata) { + if (!this._batch.length) { + this._batch = null + return + } + const operations = this._batch.map((stage) => stage.operation) + const operation = { verb: 'batch', operations, subject, metadata } + this._undoManager.add({ operation, stages: this._batch }) + this._send(operation) + this._batch = null } - delete(subject, metadata, key) { - this._send({ verb: 'delete', subject, metadata, key }) + upsert(subject, metadata, value, oldValue) { + const operation = { + verb: 'upsert', + subject, + metadata, + value, + } + const stage = { + operation, + newValue: value, + oldValue: oldValue, + } + if (this._batch) { + this._batch.push(stage) + return + } + this._undoManager.add(stage) + this._send(operation) + } + + update(subject, metadata, key, value, oldValue, { undo } = { undo: true }) { + const operation = { + verb: 'update', + subject, + metadata, + key, + value, + } + const stage = { + operation, + oldValue: oldValue, + newValue: value, + } + if (this._batch) { + this._batch.push(stage) + return + } + if (undo) this._undoManager.add(stage) + this._send(operation) + } + + delete(subject, metadata, oldValue) { + const operation = { + verb: 'delete', + subject, + metadata, + } + const stage = { + operation, + oldValue: oldValue, + } + if (this._batch) { + this._batch.push(stage) + return + } + this._undoManager.add(stage) + this._send(operation) + } + + async save() { + const needSave = new Map() + if (!this._umap.id) { + // There is no operation for first map save + needSave.set(this._umap, []) + } + for (const operation of this._operations.sorted()) { + if (operation.dirty) { + const updater = this._getUpdater(operation.subject) + const obj = updater.getStoredObject(operation.metadata) + if (!needSave.has(obj)) { + needSave.set(obj, []) + } + needSave.get(obj).push(operation) + } + } + for (const [obj, operations] of needSave.entries()) { + const ok = await obj.save() + if (!ok) break + for (const operation of operations) { + operation.dirty = false + } + } + this.saved() + this._undoManager.toggleState() } saved() { @@ -144,8 +244,8 @@ export class SyncEngine { } } - _send(inputMessage) { - const message = this._operations.addLocal(inputMessage) + _send(operation) { + const message = this._operations.addLocal(operation) if (this.offline) return if (this.transport) { @@ -153,7 +253,11 @@ export class SyncEngine { } } - _getUpdater(subject, metadata) { + _getUpdater(subject, metadata, sync) { + // For now, prevent permissions to be synced, for security reasons + if (sync && (subject === 'mappermissions' || subject === 'datalayerpermissions')) { + return + } if (Object.keys(this.updaters).includes(subject)) { return this.updaters[subject] } @@ -161,7 +265,15 @@ export class SyncEngine { } _applyOperation(operation) { + if (operation.verb === 'batch') { + operation.operations.map((op) => this._applyOperation(op)) + return + } const updater = this._getUpdater(operation.subject, operation.metadata) + if (!updater) { + debug('No updater for', operation) + return + } updater.applyMessage(operation) } @@ -304,9 +416,8 @@ export class SyncEngine { onSavedMessage({ sender, lastKnownHLC }) { debug(`received saved message from peer ${sender}`, lastKnownHLC) - if (lastKnownHLC === this._operations.getLastKnownHLC() && SaveManager.isDirty) { - SaveManager.clear() - } + this._operations.saved(lastKnownHLC) + this._undoManager.toggleState() } /** @@ -356,7 +467,7 @@ export class SyncEngine { const handler = { get(target, prop) { // Only proxy these methods - if (['upsert', 'update', 'delete'].includes(prop)) { + if (['upsert', 'update', 'delete', 'commitBatch'].includes(prop)) { const { subject, metadata } = object.getSyncMetadata() // Reflect.get is calling the original method. // .bind is adding the parameters automatically @@ -378,16 +489,22 @@ export class Operations { this._operations = new Array() } + saved(hlc) { + for (const operation of this.getOperationsBefore(hlc)) { + operation.dirty = false + } + } + /** * Tick the clock and store the passed message in the operations list. * * @param {*} inputMessage * @returns {*} clock-aware message */ - addLocal(inputMessage) { - const message = { ...inputMessage, hlc: this._hlc.tick() } - this._operations.push(message) - return message + addLocal(operation) { + operation.hlc = this._hlc.tick() + this._operations.push(operation) + return operation } /** @@ -445,6 +562,11 @@ export class Operations { return this._operations.filter((op) => op.hlc > hlc) } + getOperationsBefore(hlc) { + if (!hlc) return this._operations + return this._operations.filter((op) => op.hlc <= hlc) + } + /** * Returns the last known HLC value. */ diff --git a/umap/static/umap/js/modules/sync/undo.js b/umap/static/umap/js/modules/sync/undo.js new file mode 100644 index 00000000..47e0a3de --- /dev/null +++ b/umap/static/umap/js/modules/sync/undo.js @@ -0,0 +1,101 @@ +import * as Utils from '../utils.js' +import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js' + +export class UndoManager { + constructor(umap, updaters, syncEngine) { + this._umap = umap + this._syncEngine = syncEngine + this.updaters = updaters + this._undoStack = [] + this._redoStack = [] + } + + toggleState() { + // document is undefined during unittests + if (typeof document === 'undefined') return + const undoButton = document.querySelector('.edit-undo') + const redoButton = document.querySelector('.edit-redo') + if (undoButton) undoButton.disabled = !this._undoStack.length + if (redoButton) redoButton.disabled = !this._redoStack.length + const dirty = this.isDirty() + document.body.classList.toggle('umap-is-dirty', dirty) + for (const button of document.querySelectorAll('.disabled-on-dirty')) { + button.disabled = dirty + } + for (const button of document.querySelectorAll('.enabled-on-dirty')) { + button.disabled = !dirty + } + } + + isDirty() { + if (!this._umap.id) return true + for (const stage of this._undoStack) { + if (stage.operation.dirty) return true + } + for (const stage of this._redoStack) { + if (stage.operation.dirty) return true + } + return false + } + + add(stage) { + stage.operation.dirty = true + this._redoStack = [] + this._undoStack.push(stage) + this.toggleState() + } + + copyOperation(stage, redo) { + const operation = Utils.CopyJSON(stage.operation) + const value = redo ? stage.newValue : stage.oldValue + operation.value = value + if (['delete', 'upsert'].includes(operation.verb)) { + operation.verb = value === null || value === undefined ? 'delete' : 'upsert' + } + return operation + } + + undo(redo = false) { + const fromStack = redo ? this._redoStack : this._undoStack + const toStack = redo ? this._undoStack : this._redoStack + const stage = fromStack.pop() + if (!stage) return + stage.operation.dirty = !stage.operation.dirty + if (stage.operation.verb === 'batch') { + for (const st of stage.stages) { + this.applyOperation(this.copyOperation(st, redo)) + } + } else { + this.applyOperation(this.copyOperation(stage, redo)) + } + toStack.push(stage) + this.toggleState() + } + + redo() { + this.undo(true) + } + + applyOperation(operation) { + const updater = this._getUpdater(operation.subject, operation.metadata) + switch (operation.verb) { + case 'update': + updater.update(operation) + break + case 'delete': + updater.delete(operation) + break + case 'upsert': + updater.upsert(operation) + break + } + this._syncEngine._send(operation) + } + + _getUpdater(subject, metadata) { + if (Object.keys(this.updaters).includes(subject)) { + return this.updaters[subject] + } + throw new Error(`Unknown updater ${subject}, ${metadata}`) + } +} diff --git a/umap/static/umap/js/modules/sync/updaters.js b/umap/static/umap/js/modules/sync/updaters.js index e6055091..07d96660 100644 --- a/umap/static/umap/js/modules/sync/updaters.js +++ b/umap/static/umap/js/modules/sync/updaters.js @@ -1,4 +1,4 @@ -import { fieldInSchema } from '../utils.js' +import * as Utils from '../utils.js' /** * Updaters are classes able to convert messages @@ -10,27 +10,6 @@ class BaseUpdater { this._umap = umap } - updateObjectValue(obj, key, value) { - const parts = key.split('.') - const lastKey = parts.pop() - - // Reduce the current list of attributes, - // to find the object to set the property onto - const objectToSet = parts.reduce((currentObj, part) => { - if (currentObj !== undefined && part in currentObj) return currentObj[part] - }, obj) - - // In case the given path doesn't exist, stop here - if (objectToSet === undefined) return - - // Set the value (or delete it) - if (typeof value === 'undefined') { - delete objectToSet[lastKey] - } else { - objectToSet[lastKey] = value - } - } - getDataLayerFromID(layerId) { return this._umap.getDataLayerByUmapId(layerId) } @@ -43,13 +22,17 @@ class BaseUpdater { export class MapUpdater extends BaseUpdater { update({ key, value }) { - if (fieldInSchema(key)) { - this.updateObjectValue(this._umap, key, value) + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this._umap, key, value) } this._umap.onPropertiesUpdated([key]) this._umap.render([key]) } + + getStoredObject() { + return this._umap + } } export class DataLayerUpdater extends BaseUpdater { @@ -58,14 +41,21 @@ export class DataLayerUpdater extends BaseUpdater { try { this.getDataLayerFromID(value.id) } catch { - this._umap.createDataLayer(value, false) + const datalayer = this._umap.createDataLayer(value._umap_options || value, false) + if (value.features) { + // FIXME: this will create new stages in the undoStack, thus this will empty + // the redoStack + datalayer.addData(value) + } } } update({ key, metadata, value }) { const datalayer = this.getDataLayerFromID(metadata.id) - if (fieldInSchema(key)) { - this.updateObjectValue(datalayer, key, value) + if (key === 'options') { + datalayer.setOptions(value) + } else if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(datalayer, key, value) } else { console.debug( 'Not applying update for datalayer because key is not in the schema', @@ -82,6 +72,10 @@ export class DataLayerUpdater extends BaseUpdater { datalayer.commitDelete() } } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.id) + } } export class FeatureUpdater extends BaseUpdater { @@ -114,7 +108,7 @@ export class FeatureUpdater extends BaseUpdater { const feature = this.getFeatureFromMetadata(metadata) feature.geometry = value } else { - this.updateObjectValue(feature, key, value) + Utils.setObjectValue(feature, key, value) feature.datalayer.indexProperties(feature) } @@ -127,4 +121,32 @@ export class FeatureUpdater extends BaseUpdater { const feature = this.getFeatureFromMetadata(metadata) if (feature) feature.del(false) } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.layerId) + } +} + +export class MapPermissionsUpdater extends BaseUpdater { + update({ key, value }) { + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this._umap.permissions, key, value) + } + } + + getStoredObject(metadata) { + return this._umap.permissions + } +} + +export class DataLayerPermissionsUpdater extends BaseUpdater { + update({ key, value, metadata }) { + if (Utils.fieldInSchema(key)) { + Utils.setObjectValue(this.getDataLayerFromID(metadata.id), key, value) + } + } + + getStoredObject(metadata) { + return this.getDataLayerFromID(metadata.id).permissions + } } diff --git a/umap/static/umap/js/modules/ui/bar.js b/umap/static/umap/js/modules/ui/bar.js index f38616fa..04659db4 100644 --- a/umap/static/umap/js/modules/ui/bar.js +++ b/umap/static/umap/js/modules/ui/bar.js @@ -9,8 +9,16 @@ const TOP_BAR_TEMPLATE = `
- - + + + +
- - -