@geoleaf-plugins/editor
v1.6.2
Published
GeoLeaf Editor Plugin — Geometry creation & editing (Point/Line/Polyline/Polygon) with Terra Draw, responsive form catalog, and online/offline persistence
Readme
@geoleaf-plugins/editor
GeoLeaf plugin for creating, editing and deleting geometries (Point / Line / Polyline / Polygon) bound to map layers, with a responsive attribute form, a standardized component catalog, and online/offline persistence. Built on Terra Draw + the official MapLibre adapter.
- MIT — published on npmjs.org
- Requires
@geoleaf/coreloaded before this plugin - ESM only — no CommonJS/UMD
- Optional integration with
@geoleaf-plugins/offline-uifor offline write-through (IndexedDB sync queue) - Drawing engine is lazy-loaded on first tool activation (kept out of the initial bundle)
Installation
npm install @geoleaf-plugins/editorLoad in your HTML after @geoleaf/core:
<script
type="module"
src="node_modules/@geoleaf-plugins/editor/dist/geoleaf-editor.plugin.js"
></script>Deploy note —
geoleaf-editor.plugin.jsis a small facade that statically imports a content-hashed entry chunk (geoleaf-editor.entry-*.js): both load at boot. The drawing engine lives in two more chunks,geoleaf-editor.terra-draw-*.jsandgeoleaf-editor.modes-*.js, which the entry chunk requests withimport()the first time a drawing tool is activated. Deploy everydist/*.jsfile, the entry chunk included, together in the same directory — the chunks resolve one another by relative URL. If a service worker precaches the plugin for offline use, precache all of them: the two engine chunks are not requested at boot. Chunk names change with their content: after a redeploy that removed the old files, a page opened before it still requests the old names, so unless a cache still holds them it cannot load the engine (if it had not loaded it yet) until it is reloaded.
Quick start
Once loaded, an "Edit" button appears in the pill toolbar. Clicking it opens a floating vertical sub-menu (draggable, top-left by default) with:
- Point / Line / Polyline / Polygon — drawing tools. Click to place vertices;
Enteror double-click to finish. - Select — click an editable feature to select it and edit its vertices (drag, add midpoint, delete vertex). On a selected feature, a press within 12 px of a vertex drags that vertex, within 12 px of an edge's midpoint handle inserts a vertex there; anywhere else on the feature drags the whole feature.
- Undo / Redo —
Ctrl+Z/Ctrl+Shift+Z. - Delete — removes the selected feature (
Del).
Finishing a geometry opens an attribute form: a centered modal on desktop (≥ 768 px) or a full-screen drawer on mobile, with a target-layer dropdown filtered to layers granting edition.create or edition.update, and a compatible geometry type.
A layer becomes editable in the profile via:
{
"id": "hebergements",
"edition": { "create": true, "update": true },
"editableGeometryTypes": ["Point"],
"write": {
"enabled": true,
"endpoint": "https://backend.example/collections/hebergements",
"properties": ["name", "description", "website"]
},
"attributes": {
"fields": [
{
"field": "properties.name",
"label": "Name",
"primitive": "string",
"widget": "text",
"display": { "surfaces": ["popup"] },
"edit": { "required": true }
},
{
"field": "properties.description",
"label": "Description",
"primitive": "string",
"widget": "longtext",
"edit": {}
},
{
"field": "properties.website",
"label": "Website",
"primitive": "string",
"widget": "url",
"display": { "surfaces": ["sidepanel"] },
"edit": {}
}
]
}
}The form is a projection of attributes.fields[]: a field is captured when it declares
edit, and only then. widget serves both projections; edit.widget overrides it where
capture and display genuinely differ — a badge read as a coloured pill but captured as a
dropdown, say.
Important — Declaring
editon any field obliges the layer to declare bothedition.update: trueand awritetarget. This is rule A14 of profile validation: a profile that declares one without the others is rejected.
Public API — GeoLeaf.Editor
toggleMenu(anchorEl?)
Opens or closes the floating sub-menu.
function toggleMenu(anchorEl?: Element | null): void;setActiveTool(tool)
Syncs the active-tool highlight in the menu UI (or clears it when null). This updates the button state only; drawing is initiated through the menu/toolbar interaction flow.
function setActiveTool(tool: "point" | "line" | "polyline" | "polygon" | "select" | null): void;getActiveTool()
Returns the currently armed tool identifier, or null.
function getActiveTool(): string | null;cancelDrawing()
Disarms the armed tool and abandons the shape being traced with it: the vertices already laid
are removed, and a click on the map draws nothing more. Returns true when a tool was armed,
false when none was. Since 1.6.0.
function cancelDrawing(): boolean;It is what closing the menu does. setActiveTool(null) does not replace it — it clears the
menu's highlight and leaves the drawing engine armed. Mind the order: "armed" is read off the
tool the menu highlights, so after a setActiveTool(null) this call finds none, returns
false, and disarms nothing — and neither does discardDraft(), which
abandons a shape already finished and waiting for its form: cancelDrawing() leaves that one
alone.
updateUndoRedoState(canUndo, canRedo)
Updates the enabled state of the undo/redo buttons (advanced/host integration).
destroy()
Tears down the plugin DOM (menu + modals) and event listeners.
function destroy(): void;discardDraft(featureId?)
Abandons the draft awaiting its attribute form — a shape the user just finished drawing, or a
point captured through AddForm — as the form's cancel button would, without its
unsaved-changes confirmation. The form closes, the drawn geometry is removed together with its
undo/redo entries, and a placement marker is retired. Nothing is persisted and no event is
emitted.
function discardDraft(featureId?: string | number): boolean;featureId— abandon only if the draft is this drawn feature (detail.feature.idofgeoleaf:editor:feature-created). Omitted: whatever draft is pending, including anAddFormcapture, which has no id.- Returns
truewhen a draft was abandoned,falsewhen none is pending, when it is notfeatureId, or while its form is saving — a write in flight is never undone.
It can be called from a geoleaf:editor:feature-created listener: the event is dispatched just
before the form opens, and the form then never opens. That is the route for a host that
persists new features by its own means:
document.addEventListener("geoleaf:editor:feature-created", (e) => {
openMyOwnCreationDialog(e.detail.feature); // the host's persistence
GeoLeaf.Editor.discardDraft(e.detail.feature.id);
});It does not abandon a shape still being drawn, a geometry edit of an existing feature, or an
armed placement (GeoLeaf.Editor.PlacementMode.deactivate() does that).
Configuration (modules.editor)
The plugin reads the modules.editor block of the profile: either a file of its own,
config/plugins/editor.json, declared under Files.modules.editor, or a modules.editor block
written in profile.json. All fields are optional and fall back to the defaults below.
A root
editorConfigkey — the former place — is no longer read, and it is ignored without a message.
{
"enabled": true,
"menuPosition": "top-right",
"enabledTools": ["point", "line", "polyline", "polygon", "select", "undo", "redo", "delete"],
"api": {
"baseUrl": "https://api.example.com",
"timeoutMs": 8000
},
"persistence": {
"mode": "auto",
"dialect": "rest",
"conflictResolution": "prompt"
},
"modal": { "desktopBreakpointPx": 768, "maxWidthPx": 640 }
}| Field | Type | Default | Description |
| -------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| enabled | boolean | true | Enables / disables the entire plugin. |
| showButton | boolean | true | Shows the "Edit" pill toolbar button (alias ui.showEditor). |
| showAddPoi | boolean | true | Shows the POI capture button in the mobile pill bar. Replaces ui.showAddPoi. |
| poiAddDefaultPosition | string | "placement-mode" | Where a new POI starts when the add form opens: "placement-mode", or "geolocation" for the user's position when it is known. |
| menuPosition | string | {top,left} | "top-right" | Initial anchor of the floating sub-menu: "top-left", "top-right", "bottom-left", "bottom-right", or explicit {top,left} pixels. Defaults to the right so the pill does not sit under the core map toolbar, which owns the top-left column. |
| enabledTools | string[] | ["point", "line", "polyline", "polygon", "select", "undo", "redo", "delete"] | Subset of tools to display. |
| snapPx | number | 12 | Snap radius (px) for polygon closure on the first vertex. |
| poiSnapMeters | number | 50 | Duplicate-guard radius in metres for point capture: tapping within this ground distance of an existing feature on an editable point layer snaps onto it and reports its identity. 0 disables the guard. Not to be confused with snapPx, which is a drawing comfort measured in screen pixels. |
| showExport | boolean | true | Show the "export this session" toolbar button. Downloads the features created since page load as GeoJSON. Note that "session" means until reload: tracking is in-memory. |
| vertexHandleSize | number | 8 | Diameter (px) of draggable vertex handles. Clamped to [4, 24]. |
| midpointHandleSize | number | 5 | Diameter (px) of midpoint handles. Clamped to [3, 20]. |
| minVerticesLineString | number | 2 | Min vertices for a LineString (deletion blocked below). |
| minVerticesPolygon | number | 3 | Min vertices for a Polygon. |
| api.baseUrl | string | "" | Base URL for persistence requests. |
| api.authHeader | string | null | null | Optional Authorization header value. |
| api.timeoutMs | number | 8000 | Network timeout of a DIRECT write (mode online, or a layer this device cannot hold). A write the outbox holds is sent by the core's drain, under its own budget. |
| api.geometryProperty | string | "geom" | Geometry property key in the "collection" dialect. |
| persistence.mode | string | "auto" | "auto" (the outbox when this device can hold the layer's writes, the direct adapter otherwise), "online", or "offline" — see Persistence. Being online or not plays no part. |
| persistence.dialect | string | "rest" | "rest" ({feature, layerId} envelope) or "collection" (flat OGC/PostgREST body, create-only). |
| persistence.conflictResolution | string | "prompt" | HTTP 409 strategy: "client-wins", "server-wins", "prompt". |
| undoStackSize | number | 100 | Maximum undo/redo depth per session. |
| modal.desktopBreakpointPx | number | 768 | Width threshold: ≥ value → modal; < value → drawer. |
| modal.maxWidthPx | number | 640 | Max width of the desktop modal. |
| confirmDelete | boolean | true | Confirmation dialog before deleting. |
| confirmCancelOnDirty | boolean | true | Confirm when closing a modified form unsaved. |
| defaultLayer | string | null | null | Pre-selected target layer (null = first compatible). |
| eventNamespace | string | "editor" | Without effect: declared and validated, read by no code — the event names are fixed. |
Persistence
| Mode | Behaviour |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| online | Always the direct adapter (POST/PUT/DELETE to api.baseUrl, in persistence.dialect). A failed write is reported; it is never queued. For a deployment without a local store. |
| offline | Always the outbox: the core's offline capability holds the write, and its drain sends it to the layer's write.endpoint. |
| auto (default) | The outbox whenever this device can hold the layer's writes (GeoLeaf.Storage.canQueueWrites: the offline capability is on and the profile carries the layer) — online or not; the direct adapter otherwise. A write never changes path. |
What a creation does. Once its write has left — to the server, or to the outbox — the new
feature enters its layer's store (GeoLeaf.Layers) under the identity the write returned: the
server's id, or, for a write the outbox holds, the client key the core minted (loc:…), which
the layer keeps for the rest of the session. The layer draws it, GeoLeaf.Layers.search and
getFeatureById find it, and geoleaf:editor:feature-saved carries that identity. A drawn shape
is handed over: it leaves the drawing layer and is selected through its layer like any other
feature, so its later edits reach the outbox or the server under that identity. Drawn by its layer, it
follows the layer's visibility and zoom range. The shape stays in the drawing layer only when the
layer cannot take the feature: a vector-tile layer, a layer the store does not hold, or a backend
answering a creation without an id.
REST dialect (dialect: "rest"): POST/PUT/DELETE {baseUrl}/features?layerId=… with a { feature, layerId } envelope.
Collection dialect (dialect: "collection"): POST {baseUrl}/{layerId} with a flat { ...properties, geom: geometry } body (OGC API Features / PostgREST). Create-only at this time.
⚠️ Two defaults that differ, for two paths that differ. persistence.dialect defaults to
"rest"; a layer's write.dialect defaults to "collection". persistence.dialect is what the
DIRECT adapter speaks, to api.baseUrl — it does not read the layer's write block. A write held
by the outbox is sent by the core's drain to the layer's write.endpoint, in collection,
whatever persistence.dialect says. A layer written through both paths needs the two to name
the same server contract.
On HTTP 409, conflict resolution follows persistence.conflictResolution; "prompt" opens a merge dialog.
Internationalisation
Six languages bundled (FR/EN/ES/PT/IT/DE) via GeoLeaf.I18n. Unknown locales fall back to French. All UI strings (tools, history, modal, sync, errors, ARIA) are translated; no hardcoded labels.
AddPOI merged in — breaking, no alias
@geoleaf-plugins/addpoi merged into this plugin in v3. There is one editing plugin now, and
GeoLeaf.AddPOI was removed without an alias.
| Before | Now |
| ----------------------------------------- | ----------------------------------------- |
| GeoLeaf.AddPOI.AddForm.openAddForm(ll) | GeoLeaf.Editor.AddForm.openAddForm(ll) |
| GeoLeaf.AddPOI.PlacementMode.activate() | GeoLeaf.Editor.PlacementMode.activate() |
| ui.showAddPoi | modules.editor.showAddPoi |
| modules.addpoi.defaultPosition | modules.editor.poiAddDefaultPosition |
| ui.showPoiExport | modules.editor.showExport |
Warning — The two
ui.showPoi*flags are not carried over under those names: neither was declared in any profile schema, andui.schema.jsonisadditionalProperties: false, so writing them fails profile validation. Their replacements live undermodules.editor.*and are declared.
ui.showAddPoiwas read by the core, which drew the button itself. It now belongs to the plugin: the button is a lazy toolbar slot, declared by the host app before the bundle loads.
Security notes (for integrators)
The plugin renders user data via textContent / DOM APIs (no innerHTML with user input) and whitelists URL protocols (http, https, mailto, tel) — javascript:/data: URIs are blocked. Client-side upload validation checks MIME type and size. You must, on the server:
- Re-validate uploaded file MIME type, size and content (reject polyglot/EXIF-injected files; prevent path traversal).
- Serve the host page with a Content-Security-Policy, e.g.
default-src 'self'; script-src 'self'; img-src 'self' data: https:;.
Further security guidance: Security, and the
repository policy in
.github/SECURITY.md.
Bundle budget
| Part | Size (gzip, approx.) | Loaded |
| ---------------------------------------------------------- | -------------------- | -------------------------------- |
| Facade geoleaf-editor.plugin.js + entry chunk | ~53 KB | At boot |
| Terra Draw engine chunk (geoleaf-editor.terra-draw-*.js) | ~45 KB | Lazily, on first tool activation |
| Modes chunk (geoleaf-editor.modes-*.js) | < 1 KB | Lazily, with the engine |
Sizes are approximate and move with each release. In the GeoLeaf-JS repository, npm run size -- --plugins measures them on the built dist/: the boot (the facade plus every chunk it imports statically) and the total of all dist/*.js files — the lazy chunks are the difference.
maplibre-gl is a peer dependency (host page), excluded from the bundle. @geoleaf/core is external.
License
MIT — © 2026 Mattieu Pottier. See LICENSE.
