www.spaceplanner.app

Web client to the spaceplanner API
git clone git://jacobedwards.org/www.spaceplanner.app
Log | Files | Refs

commit 223ee24b507bb61b3c84a180bb1161732b16acee
parent cc0fc8361bdca00e47b434cd8ea20936a2137e0b
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Sun, 25 Aug 2024 14:13:15 -0700

Add selection system to editor and improve frontend

- Add new selection system to the editor, and use that to make a
  single selection handler
- Add a general purpose editor remove function which will remove
  the given elements or references from the backend
- Re-plot pointmaps when their points move
- Add and edit a few functions to work with refs in the editor
- Rewrite handlers in the front-end to work with the new systems
  in the editor

Diffstat:
Mfiles/floorplans/floorplan/editor.js | 217++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mfiles/floorplans/floorplan/main.js | 506++++++++++++++++++++++++++++++++++++++-----------------------------------------
2 files changed, 418 insertions(+), 305 deletions(-)

diff --git a/files/floorplans/floorplan/editor.js b/files/floorplans/floorplan/editor.js @@ -1,15 +1,80 @@ import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js" import * as backend from "./backend.js" -SVG.extend(SVG.Element, { - select: function() { - console.debug("User selected", this.node) - this.root().find(".last_selected") +const selectEvent = new Event("select") +const unselectEvent = new Event("unselect") + +SVG.extend(SVG.Svg, { + unselect: function(list) { + console.debug("Svg.unselect", list) + + let selected + if (list) { + selected = list + } else { + selected = this.find(".selected") + } + + // Should also test for selected class + if (!selected) { + console.debug("Nothing to unselect") + return + } + + this.find(".last_selected") .removeClass("last_selected") - this.root().find(".selected") + + let unselected = selected .removeClass("selected") .addClass("last_selected") - return this.addClass("selected") + + // NOTE: Could fire an event, but then I'd have to handle + // deletions, so I'll leave it until it's needed. + return unselected + }, + + select: function(list) { + console.debug("Svg.select", list) + + this.unselect() + + if (list) { + list.addClass("selected") + } + this.fire("select", { selected: list }) + return list + }, + + reselect: function() { + this.fire("select", { selected: this.find(".selected") }) + } +}) + +SVG.extend(SVG.List, { + selectList: function() { + let root + this.each(function(item) { + if (!root) { + root = item.root() + } else if (root != item.root()) + throw new Error("Cannot select from different documents") + }) + + return root.select(this) + }, + + array: function() { + let a = [] + this.each(function(item) { + a.push(item) + }) + return a + } +}) + +SVG.extend(SVG.Element, { + select: function() { + return new SVG.List([this]).selectList()[0] }, findOneMax: function(selector) { @@ -240,6 +305,26 @@ export class FloorplanEditor { editor.updateGrid() }) resize.observe(editor.draw.node) + + let selectionRemoval = new MutationObserver(function(mutations) { + for (const m of mutations) { + if (m.type === "childList" && m.removedNodes) { + m.removedNodes.forEach(function(node) { + if (node.classList.contains("selected")) { + console.debug("selectionRemoval", + "Detected selected node being removed") + editor.draw.reselect() + return + } + }) + } + } + }) + selectionRemoval.observe(this.draw.node, { childList: true, subtree: true }) + + this.draw.on("select", function(event) { + editor.selection = event.detail.selection + }) } useUnits(system) { @@ -383,27 +468,59 @@ export class FloorplanEditor { return this.backend.addPoint(point) } - removePoint(point) { - this.backend.removePoint(getId(point), { recurse: true }) + remove(...elements) { + let later = [] + + for (let i in elements) { + let ref = getRef(elements[i]) + if (ref.type === "pointmaps") { + this.backend.unmapPoints(ref.id) + } else { + later.push(ref) + } + } + + for (let i in later) { + if (later[i].type === "points") { + this.backend.removePoint(later[i].id, { unmap: true }) + } else { + throw new Error("Unsupported type") + } + } + + this.backend.removeOrphans() this.updateDisplay() } + removePoints(...points) { + for (let i in points) { + points[i] = backend.newRef("points", getId(points[i])) + } + return remove(points) + } + pointAt(point) { return this.thingAt(point, "#points") } - thingAt(point, selector) { + return this.thingsAt(point, selector, 1)[0] + } + + thingsAt(point, selector, max) { let children = this.draw.find(selector ?? "*") .children() .toArray() + let inside = [] for (let i in children) { if (children[i].inside(point.x, point.y)) { - return children[i] + if (inside.push(children[i]) >= max) { + return inside + } } } - return null + return inside } mapSelected(type) { @@ -412,14 +529,9 @@ export class FloorplanEditor { } mapPoints(type, p1, p2) { - let pointId = function(id) { return id.split("_")[1] } - - this.mapPointsById(type, pointId(p1.attr("id")), pointId(p2.attr("id"))) - } - - mapPointsById(type, p1, p2) { - this.backend.mapPoints(type, p1, p2) + let ref = this.backend.mapPoints(type, getId(p1, "points"), getId(p2, "points")) this.updateDisplay() + return ref } selectedPoints() { @@ -461,7 +573,7 @@ export class FloorplanEditor { let ops = { add: { - points: function(name, value) { + points: function(name, value, ref) { let cur = editor.draw.findOneMax(byId(name)) // Update pointmaps if (cur) { @@ -474,14 +586,18 @@ export class FloorplanEditor { .attr({ id: name }) .addClass("point") .select() - .on("click", function(event) { - if (event.shiftKey) { - this.select() - event.preventDefault() - } - }) } + for (let oth in editor.backend.mappedPoints[ref.id]) { + let map = editor.backend.mappedPoints[ref.id][oth] + oth = editor.backend.reqId("points", oth) + map = editor.draw.findOneMax(byId(refId((backend.newRef("pointmaps", map))))) + if (map) { + // It's probably being added later, that said, this isn't a good solution + // because it doesn't allow for checking for errors. + map.plot(oth.x, oth.y, value.x, value.y) + } + } }, pointmaps: function(name, value) { if (value.type !== "wall" && value.type !== "door") { @@ -528,7 +644,7 @@ export class FloorplanEditor { if (!ops[diff.op][ref.type]) { throw new Error("Unhandled patch") } - ops[diff.op][ref.type](refId(ref), diff.value) + ops[diff.op][ref.type](refId(ref), diff.value, ref) } updateId(ids) { @@ -538,7 +654,7 @@ export class FloorplanEditor { } findRef(ref) { - return this.draw.findExactlyOne(byId(refId(ref))) + return this.draw.findExactlyOne(byId(refId(getRef(ref)))) } } @@ -592,32 +708,51 @@ function gridSystem(editor, system) { return last } -function getId(thing) { - console.debug("getId", thing) +export function getRef(thing, type) { + console.debug("getRef", thing, type) + let ref if (typeof thing === "object") { - return idRef(thing.attr("id")).id + if (typeof thing.attr === "function") { + ref = idRef(thing.attr("id")) + } else if (typeof thing.type === "string" && typeof thing.id === "number") { + ref = thing + } + } else if (typeof thing === "string") { + ref = idRef(thing) } - if (typeof thing === "string") { - return idRef(thing).id + + if (!ref) { + console.error("Couldn't get ref from", thing) + throw new Error("Invalid ref") } - if (typeof thing === "number") { - return thing + if (type && ref.type != type) { + throw new Error(`${ref.type}: Invalid ref type (wanted ${type})`) } - throw new Error("Invalid ID") + return ref } -function byId(id) { - return "#" + id -} +export function getId(thing, type) { + console.debug("getId", thing) -function refId(ref) { - return ref.type + "_" + ref.id + let n = Number(thing) + if (isNaN(n)) { + return getRef(thing, type).id + } + return n } export function idRef(id) { let a = id.split("_") if (a.length != 2) { - throw new Error("Invalid id") + throw new Error(`${id}: Invalid id`) } return backend.newRef(a[0], a[1]) } + +function byId(id) { + return "#" + id +} + +function refId(ref) { + return ref.type + "_" + ref.id +} diff --git a/files/floorplans/floorplan/main.js b/files/floorplans/floorplan/main.js @@ -2,9 +2,10 @@ import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js" import "/lib/github.com/svgdotjs/svg.panzoom.js/svg.panzoom.js" import * as ui from "/lib/ui.js" import * as etc from "/lib/etc.js" -import { FloorplanEditor as Editor, idRef } from "./editor.js" +import * as lib from "./editor.js" // Confusing, but I don't want to fix variable conflict import { Vector2 } from "/lib/github.com/ros2jsguy/threejs-math/math/Vector2.js" import "./geometry.js" +import * as backend from "./backend.js" const messageTimeout = 4000 @@ -36,7 +37,7 @@ function init() { zoomFactor: .5 }) - let editor = new Editor(draw, + let editor = new lib.FloorplanEditor(draw, { user: localStorage.getItem("username"), name: floorplan }, { backend: { callbacks: { @@ -106,6 +107,8 @@ function init() { ) )) + editor.draw.on("select", function(event) { selectHandler(event, editor) }) + editor.backend.pull() .then(function() { if (editor.draw.findExactlyOne("#points").children().length === 0) { @@ -114,6 +117,63 @@ function init() { }) } +function selectHandler(event, editor) { + let old = document.getElementById("selOps") + if (!event.detail.selected) { + if (old) { + old.remove() + } + return + } + let a = event.detail.selected.array() + let c = document.createElement("li") + c.setAttribute("id", "selOps") + + c.appendChild(document.createTextNode("Selection: ")) + c.append(ui.input("Delete", "Delete selected objects", { + attributes: { type: "button" }, + handlers: { click: function() { + editor.remove(...a) + }}, + }) + ) + + let refs = [] + for (let i in a) { + refs[i] = lib.getRef(a[i]) + } + + let maps = [] + for (let i in refs) { + if (refs[i].type === "pointmaps") { + maps.push(editor.backend.cache.pointmaps[refs[i].id]) + } + } + + if (maps.length > 0) { + c.appendChild( + radioMenu(editor, "Type", ["wall", "door"], null, { + nosubmit: true, + callbacks: { + change: function(newvalue) { + for (let i in maps) { + editor.mapPoints(newvalue, maps[i].a, maps[i].b) + editor.updateDisplay() + } + } + } + }) + ) + } + + if (old) { + old.replaceWith(c) + } else { + document.querySelector(".toolbar") + .appendChild(c) + } +} + function selector(editor, things, select, options) { options = options ?? {} @@ -165,108 +225,54 @@ let modes = { * though. */ contextmenu: preventDefaultHandler, - click: addWallHandler + mousedown: selectionHandler } }, Precise: { points: true, handlers: { contextmenu: preventDefaultHandler, - mousedown: precisePointHandler, + mousedown: [selectionHandler, precisePointHandler], mousemove: precisePointHandler, mouseup: precisePointHandler, - keydown: [zoomKeysHandler, undoRedoHandler, pointMapTypeHandler, precisePointHandler], - click: [precisePointHandler, pointMapTypeHandler], + keydown: [zoomKeysHandler, undoRedoHandler, precisePointHandler], + click: precisePointHandler, dblclick: precisePointHandler, } } } -function zoomKeysHandler(event, editor) { - if (event.key === "+") { - editor.draw.zoom(editor.draw.zoom() * 1.25) - } else if (event.key === "-" || event.key === "_") { - editor.draw.zoom(editor.draw.zoom() / 1.25) - } else { +// mousedown +function selectionHandler(event, editor) { + if (event.button != buttons.left) { return } - event.preventDefault() -} -// click, keydown -function pointMapTypeHandler(event, editor, state) { - const cleanup = function() { - state.menu.remove() - for (let i in state) { - delete state[i] - } - } - const commit = function() { - editor.finishAction() - cleanup() - } - const cancel = function() { - // NOTE: I would use editor.undo(), but I'm not sure - // if I'll allow asynchronous menus,etc. in the future - editor.mapPointsById(state.orig, state.map.a, state.map.b) - cleanup() - } - const change = function(newvalue) { - editor.mapPointsById(newvalue, state.map.a, state.map.b) - editor.updateDisplay() - } + let p = editor.draw.point(event.clientX, event.clientY) - if (event.type === "keydown") { - if (!state.menu) { - return - } - if (event.key === "Enter") { - commit(event) - } else if (event.key === "Escape") { - cancel(event) - } else { - return - } - event.preventDefault() + let x = editor.thingAt(p, "#points") + if (x) { + x.select() return } - if (event.type != "click") { + x = editor.thingAt(p, "#pointmaps") + if (x) { + x.select() return } - // No matter where the user clicks, the old - // menu should canceled - if (state.menu) { - cancel() - } - - let cursor = editor.draw.point(event.clientX, event.clientY) - let point = editor.thingAt(cursor, "#points") - if (point) { - return - } + editor.draw.select() +} - let map = editor.thingAt(cursor, "#pointmaps") - if (!map) { +function zoomKeysHandler(event, editor) { + if (event.key === "+") { + editor.draw.zoom(editor.draw.zoom() * 1.25) + } else if (event.key === "-" || event.key === "_") { + editor.draw.zoom(editor.draw.zoom() / 1.25) + } else { return } - - map.select() - let ref = idRef(map.attr("id")) - state.map = editor.backend.cache.pointmaps[ref.id] - state.orig = state.map.type - if (state.menu) { - throw new Error("Menu should have already been removed") - } - state.menu = document.body.querySelector(".toolbar") - .appendChild( - item(radioMenu(editor, "Type", ["wall", "door"], state.orig, { callbacks: { - commit: commit, - change: change - }})) - ) - event.preventDefault() } @@ -298,6 +304,10 @@ function radioMenu(editor, key, values, initial, options) { container.append(radios[i]) } + if (options.nosubmit) { + return menu + } + container.appendChild(document.createTextNode(" ")) let submit = container.appendChild(document.createElement("input")) submit.setAttribute("type", "submit") @@ -351,124 +361,9 @@ function undoRedoHandler(event, editor) { event.preventDefault() } +// mousedown, mousemove, mouseup function precisePointHandler(event, editor, state) { - const subs = { - add: preciseAddPointHandler, - edit: preciseEditPointHandler - } - const callSubWithEvent = function(name, event) { - state[name] = state[name] ?? {} - let done = subs[name](event, editor, state[name]) - if (event.type === "cleanup") { - return - } - if (done) { - delete state.handler - } else if (event.defaultPrevented && name !== "add") { - state.handler = name - } - return done - } - const callSub = function(name) { - callSubWithEvent(name, event) - } - const cleanupSub = function(name) { - callSubWithEvent(name, { type: "cleanup" }) - } - - if (state.handler) { - if (callSub(state.handler)) { - callSub("add") - } - } - - callSub("add") - if (event.type === "dblclick" && !event.defaultPrevented) { - callSub("edit") - if (event.defaultPrevented) { - callSubWithEvent("add", { type: "cleanup" }) - } - } -} - -// mousedown, mousemove, mouseup, keydown, dblclick -function preciseAddPointHandler(event, editor, state) { - const cleanup = function() { - state.line.remove() - state.point.remove() - state.menu.remove() - for (let i in state) { - delete state[i] - } - } - const updatePoint = function(p, options) { - options = options ?? {} - let origin = state.from.vec() - state.point.move(p.x, p.y) - let instead = editor.pointAt(p) - if (instead && instead != state.from) { - state.point.hide() - p = instead.select().pos() - state.gotsnapped = true - } else if (state.gotsnapped) { - state.gotsnapped = false - state.point.show().select() - } - state.line.plot(origin.x, origin.y, p.x, p.y) - if (!options.leave_input) { - state.len.value = userLength(editor, - editor.units.snapTo(origin.distanceTo(p), editor.unit)) - } - } - const addWall = function() { - state.point.remove() - let p = editor.addPoint(state.point.pos()) - editor.mapPoints("wall", state.from, p) - cleanup() - editor.finishAction() - } - - if (event.type === "cleanup") { - if (state.point) { - cleanup() - } - return - } - - if (!event.key && event.button !== buttons.left) { - return - } - - let p = editor.draw.point(event.clientX, event.clientY).vec() - if (event.type === "dblclick") { - if (state.point && state.from.vec().distanceTo(p) > 0) { - addWall() - event.preventDefault() - } - return - } - - if (event.type === "mousedown") { - if (state.point) { - if (state.point.inside(p.x, p.y)) { - state.moving = true - } - event.preventDefault() - return - } - - state.from = editor.pointAt(p) - if (!state.from) { - return - } - state.moving = true - state.line = editor.ui.bottom.line() - .addClass("wall") - .addClass("preview") - state.point = editor.ui.top.circle() - .addClass("point") - .addClass("preview") - .select() + const init = function() { state.menu = document.body.querySelector(".toolbar") .appendChild(document.createElement("li")) state.menu.classList.add("menu") @@ -495,95 +390,174 @@ function preciseAddPointHandler(event, editor, state) { updatePoint(vecs[1], { leave_input: true }) } }) - event.preventDefault() - return } - - if (!state.line) { - return + const cleanup = function() { + if (state.moveTimeout != null) { + clearTimeout(state.moveTimeout) + } + if (state.menu != undefined) { + state.menu.remove() + } + for (let i in state) { + if (i !== "lastUp") { + delete state[i] + } + } } + const updatePoint = function(p, options) { + options = options ?? {} - if (event.type === "mousemove") { - if (!state.moving) { - return + if (state.snapmap == null) { + editor.movePoint(state.to, p) + editor.updateDisplay() } - let sp = state.from.vec() - p = snap(editor.units.snapTo(p, editor.unit), sp, 8) - updatePoint(p) - } else if (event.type === "mouseup") { - if (state.from.inside(p.x, p.y)) { - cleanup(); - } else { - state.moving = false - state.lastmoving = Date.now() + + let points = editor.thingsAt(p, "#points") + let fid = lib.getId(state.from) + let tid + if (state.snapmap == null) { + tid = lib.getId(state.to) } - } else if (event.type === "keydown") { - if (event.key === "Enter") { - addWall() - return - } else if (event.key !== "Escape") { - return + let instead + for (let i in points) { + let id = lib.getId(points[i]) + if (id !== tid && id !== fid) { + instead = id + } } - cleanup(); - } else { - return - } - event.preventDefault() -} -// mousedown, dblclick -function preciseEditPointHandler(event, editor, state) { - const cleanup = function() { - state.menu.remove() - for (let i in state) { - delete state[i] + if (instead != undefined) { + if (instead !== state.to) { + if (state.snapmap == null) { + editor.remove(state.to) + } else { + editor.remove(state.snapmap) + } + state.to = editor.findRef(backend.newRef("points", instead)) + state.snapmap = editor.mapPoints("wall", state.from, state.to) + } + } else if (state.snapmap != null) { + editor.remove(state.snapmap) + state.snapmap = null + state.to = editor.addPoint(p, true) + editor.mapPoints("wall", state.from, state.to) + editor.updateDisplay() + state.to = editor.findRef(state.to) } - state.done = true - } - if ((event.type !== "dblclick" && event.type !== "mousedown") || - event.button != buttons.left) { - return state.done + if (!options.leave_input) { + state.len.value = userLength(editor, + editor.units.snapTo(state.origin.distanceTo(p), editor.unit)) + } + } + const doMove = function() { + // This is racy + state.moveTimeout = null + updatePoint(snap(editor.units.snapTo(state.move, editor.unit), state.origin, 8)) + } + const revert = function() { + /* + * NOTE: WARNING: If allowing asyncronous edits this would be bad + * I should introduce a revert function which takes diffs and reverts + * them specifically, and I suppose pass a diff id with every single action. + * I think asyncronous actions would add very little in terms of value, + * and take time to implement. Better to disallow for now. + */ + editor.finishAction() + editor.undo() + cleanup() + } + const commit = function() { + editor.finishAction() + cleanup() } - let point = editor.pointAt(editor.draw.point(event.clientX, event.clientY)) - if (!point) { - if (state.point) { - cleanup() - event.preventDefault() - return true - } + if (event.button !== buttons.left) { return } - if (state.point && state.point != point) { - cleanup() - event.preventDefault() - return true + let cursor = editor.draw.point(event.clientX, event.clientY).vec() + if (event.type === "mouseup") { + state.lastUp = Date.now() } - if (event.type === "dblclick") { - if (state.menu) { + if (state.to == undefined) { + if (event.type === "mousedown") { + if (state.from != undefined) { + return + } + + state.from = editor.selectedPoint() + if (!state.from) { + return + } + + if (state.lastUp != null && elapsed(state.lastUp) <= 500) { + state.to = state.from + state.from = null + + // I want the first pointmap defined, but this for now + let m = editor.backend.mappedPoints[lib.getId(state.to)] + for (let point in m) { + state.from = editor.findRef(backend.newRef("points", point)) + break + } + if (!state.from) { + // I mean, there really shouldn't be an orphaned point, + // and I see no reason to move the only point in the plan + cleanup() + throw new Error("Can't move unmapped points") + } + init() + } + + state.origin = state.from.vec() + } else if (event.type === "mouseup") { cleanup() + return // Or should I preventDefault()? + } else if (event.type === "mousemove" && state.origin != undefined && + state.origin.distanceTo(cursor) > 200) { + state.to = editor.addPoint(cursor, true) + editor.mapPoints("wall", state.from, state.to) + editor.updateDisplay() + state.to = editor.findRef(state.to) + init() } - state.done = false - state.point = point - state.menu = document.createElement("li") - state.menu.appendChild(document.createTextNode("Point: ")) - state.menu.appendChild( - ui.input("Delete", "Delete point", { - attributes: { type: "button" }, - handlers: { click: function() { - editor.removePoint(state.point) - cleanup() - }}, - }) - ) - state.point.select() - document.querySelector(".toolbar") - .appendChild(state.menu) event.preventDefault() + return } + + if (state.to == undefined) { + return + } + if (!state.from) { + throw new Error("Hmm") + } + + if (event.type === "mousemove") { + // This is still far too expensive, it runs up my fans in seconds. + state.move = cursor + if (state.moveTimeout == null) { + state.moveTimeout = setTimeout(doMove, 35) + } + } else if (event.type === "mouseup") { + if (state.from && state.from.inside(cursor.x, cursor.y)) { + revert() + } else { + // Not that it makes much difference, but should probably use + // state.to's position + if (state.to && state.origin.distanceTo(cursor) > 0) { + commit() + } else { + cleanup() + } + } + } else { + console.warn("Bit of a state mismatch, not that big of a deal though") + commit() + return + } + event.preventDefault() } function parseUserLength(editor, length) { @@ -709,4 +683,8 @@ function item(node) { return i } +function elapsed(since) { + return Date.now() - since +} + window.onload = init