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 761945b0..f999b96b 100644
--- a/umap/static/umap/js/modules/data/layer.js
+++ b/umap/static/umap/js/modules/data/layer.js
@@ -49,7 +49,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 +65,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 = {}
@@ -269,13 +269,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 +364,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 +414,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 +460,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 +521,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
}
}
@@ -596,10 +599,11 @@ 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)
}
}
@@ -1086,7 +1090,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() {
@@ -1167,7 +1175,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/form/builder.js b/umap/static/umap/js/modules/form/builder.js
index d0461036..df3396ec 100644
--- a/umap/static/umap/js/modules/form/builder.js
+++ b/umap/static/umap/js/modules/form/builder.js
@@ -190,13 +190,13 @@ export class MutatingForm extends Form {
}
setter(field, value) {
+ const oldValue = this.getter(field)
super.setter(field, value)
- this.obj.isDirty = true
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..9e6f3521 100644
--- a/umap/static/umap/js/modules/permissions.js
+++ b/umap/static/umap/js/modules/permissions.js
@@ -13,6 +13,7 @@ export class MapPermissions extends ServerStored {
this.setProperties(umap.properties.permissions)
this._umap = umap
this._isDirty = false
+ this.sync = umap.syncEngine.proxy(this)
}
setProperties(properties) {
@@ -28,6 +29,13 @@ export class MapPermissions extends ServerStored {
)
}
+ getSyncMetadata() {
+ return {
+ subject: 'mappermissions',
+ metadata: {},
+ }
+ }
+
render() {
this._umap.render(['properties.permissions'])
}
@@ -259,6 +267,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) {
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/saving.js b/umap/static/umap/js/modules/saving.js
index 501fe19a..d5181a37 100644
--- a/umap/static/umap/js/modules/saving.js
+++ b/umap/static/umap/js/modules/saving.js
@@ -31,7 +31,7 @@ function has(obj) {
function onUpdate() {
isDirty = Boolean(_queue.size)
- document.body.classList.toggle('umap-is-dirty', isDirty)
+ // document.body.classList.toggle('umap-is-dirty', isDirty)
}
export class ServerStored {
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..ccdd3dff 100644
--- a/umap/static/umap/js/modules/sync/engine.js
+++ b/umap/static/umap/js/modules/sync/engine.js
@@ -1,7 +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 { UndoManager } from './undo.js'
import * as SaveManager from '../saving.js'
// Start reconnecting after 2 seconds, then double the delay each time
@@ -55,6 +62,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 +73,7 @@ export class SyncEngine {
this.websocketConnected = false
this.closeRequested = false
this.peerId = Utils.generateId()
+ this._undoManager = new UndoManager(this.updaters, this)
}
get isOpen() {
@@ -122,16 +132,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 +245,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 +254,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 +266,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 +417,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 +468,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 +490,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 +563,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..954b6955
--- /dev/null
+++ b/umap/static/umap/js/modules/sync/undo.js
@@ -0,0 +1,97 @@
+import * as Utils from '../utils.js'
+import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
+
+export class UndoManager {
+ constructor(updaters, syncEngine) {
+ this._syncEngine = syncEngine
+ this.updaters = updaters
+ this._undoStack = []
+ this._redoStack = []
+ }
+
+ toggleState() {
+ 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() {
+ 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..7388168d 100644
--- a/umap/static/umap/js/modules/sync/updaters.js
+++ b/umap/static/umap/js/modules/sync/updaters.js
@@ -50,6 +50,10 @@ export class MapUpdater extends BaseUpdater {
this._umap.onPropertiesUpdated([key])
this._umap.render([key])
}
+
+ getStoredObject() {
+ return this._umap
+ }
}
export class DataLayerUpdater extends BaseUpdater {
@@ -58,7 +62,12 @@ 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)
+ }
}
}
@@ -82,6 +91,10 @@ export class DataLayerUpdater extends BaseUpdater {
datalayer.commitDelete()
}
}
+
+ getStoredObject(metadata) {
+ return this.getDataLayerFromID(metadata.id)
+ }
}
export class FeatureUpdater extends BaseUpdater {
@@ -127,4 +140,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 (fieldInSchema(key)) {
+ this.updateObjectValue(this._umap.permissions, key, value)
+ }
+ }
+
+ getStoredObject(metadata) {
+ return this._umap.permissions
+ }
+}
+
+export class DataLayerPermissionsUpdater extends BaseUpdater {
+ update({ key, value, metadata }) {
+ if (fieldInSchema(key)) {
+ this.updateObjectValue(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..61d079e9 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 = `
-