diff --git a/umap/static/umap/css/bar.css b/umap/static/umap/css/bar.css
index 5df3bd87..a3f4e5ff 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;
}
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 @@
-
+
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 @@
-
+
diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js
index ee168ece..f7202b89 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 92808cc2..25357e88 100644
--- a/umap/static/umap/js/modules/data/layer.js
+++ b/umap/static/umap/js/modules/data/layer.js
@@ -18,7 +18,6 @@ import { translate } from '../i18n.js'
import { DataLayerPermissions } from '../permissions.js'
import { Point, LineString, Polygon } from './features.js'
import TableEditor from '../tableeditor.js'
-import { ServerStored } from '../saving.js'
import * as Schema from '../schema.js'
import { MutatingForm } from '../form/builder.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
}
})
}
@@ -1092,7 +1072,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() {
@@ -1121,6 +1105,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) {
@@ -1130,15 +1118,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()
}
)
}
@@ -1173,7 +1154,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 d0461036..321c49a3 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 130b0d85..d958c345 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 d206496a..27c5de69 100644
--- a/umap/static/umap/js/modules/permissions.js
+++ b/umap/static/umap/js/modules/permissions.js
@@ -1,18 +1,16 @@
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js'
import { uMapAlert as Alert } from '../components/alerts/alert.js'
-import { ServerStored } from './saving.js'
import * as Utils from './utils.js'
import { MutatingForm } from './form/builder.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 6b8f39aa..370ba70e 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 4a3d584f..4b354165 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 f03fc20c..8835d77d 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 08b76cc5..602c7610 100644
--- a/umap/static/umap/js/modules/sync/engine.js
+++ b/umap/static/umap/js/modules/sync/engine.js
@@ -1,8 +1,14 @@
import * as Utils from '../utils.js'
import { HybridLogicalClock } from './hlc.js'
-import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
+import {
+ DataLayerUpdater,
+ FeatureUpdater,
+ MapUpdater,
+ MapPermissionsUpdater,
+ DataLayerPermissionsUpdater,
+} from './updaters.js'
import { WebSocketTransport } from './websocket.js'
-import * as SaveManager from '../saving.js'
+import { UndoManager } from './undo.js'
// Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds.
@@ -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 d7bf77de..4e528212 100644
--- a/umap/static/umap/js/modules/ui/bar.js
+++ b/umap/static/umap/js/modules/ui/bar.js
@@ -22,15 +22,19 @@ const TOP_BAR_TEMPLATE = `
-