Object tree: groups and the drawing order
Deploy web Studio / deploy (push) Successful in 1m5s

Groups: objects of type "group" draw nothing; members carry the group's id
in a new `group` field (groups nest). A hidden group hides everything in it
(Doc::hidden_in_groups, skipped by the driver), a locked one locks it in
the Studio. Removing a group moves its members up into its parent group.
isokit check warns about members of missing groups. New `order` op: the
whole object order at once (each id once), one undo step.

Studio object list as a tree in the document order, front first: each
group sits just after its members, so it draws as one block, and ground
pieces higher in the list lie on top. Groups fold open and shut; every row
has an eye and a lock (shown on hover or when set); a click on a group
selects all of it, so a drag on the map moves it all. Group / Ungroup
(Ctrl+G, Ctrl+Shift+G), Order up/down (Ctrl+] Ctrl+[, with Shift to the
front / back), drag rows above, below or into a group. Group panel: name,
zoom, ungroup, to front / back, lock, hide, delete with contents.

MCP: group field in the object schema, order op, guidance.

Tests: Rust 81 (ops: groups, order, removal; driver: nested hidden
groups), Studio 131, web 100.
This commit is contained in:
grabowski committed 2026-10-25 18:06:04 +07:00
1 parent 5ec164a633
commit 540b37020c
15 files changed
+573 -30

No files matched your search

+7
View File
@@ -38,6 +38,13 @@ document.documentElement.setAttribute("data-theme",t);})();
<section class="st-panel st-grow-panel">
<h2>Objects <small id="ocount"></small></h2>
<input type="search" id="osearch" placeholder="Filter…" autocomplete="off">
<div class="st-otools">
<button type="button" id="ogroup" title="Group the selected objects (Ctrl+G)">▤ Group</button>
<button type="button" id="oungroup" title="Ungroup (Ctrl+Shift+G)">Ungroup</button>
<span title="The order draws ground pieces (areas, beds, ponds, roads): higher in the list lies on top. Drag rows to reorder, or into a group">Order</span>
<button type="button" id="ofwd" title="Forward: draw on top of the one above (Ctrl+]); Shift: to the front (Ctrl+Shift+])">▲</button>
<button type="button" id="obwd" title="Backward (Ctrl+[); Shift: to the back (Ctrl+Shift+[)">▼</button>
</div>
<ul id="objects" class="st-objects"></ul>
</section>
</aside>
+15
View File
@@ -54,6 +54,21 @@ input[type=checkbox] { accent-color: var(--accent); }
.st-objects li span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-objects li small { color: var(--mute); }
.st-objects li.st-hidden span { opacity: .5; text-decoration: line-through; }
/* the object tree: groups indent what is in them; a hide and a lock toggle per row, shown on hover or when set */
.st-objects li { padding-left: calc(2px + var(--d, 0) * 14px); }
.st-objects li .st-tw { width: 12px; flex: none; font-weight: normal; color: var(--mute); text-align: center; cursor: pointer; }
.st-objects li.st-group > span { font-weight: 600; }
.st-objects li.st-inh span { text-decoration: none; }
.st-objects li button { flex: none; border: 0; background: none; padding: 0 2px; min-width: 18px; color: var(--mute); cursor: pointer; font-size: 12px; line-height: 1; opacity: 0; }
.st-objects li:hover button, .st-objects li button.st-on, .st-objects li button:focus-visible { opacity: 1; }
.st-objects li button:hover { color: var(--fg); }
.st-objects li.st-drop-above { box-shadow: inset 0 2px 0 var(--accent); }
.st-objects li.st-drop-below { box-shadow: inset 0 -2px 0 var(--accent); }
.st-objects li.st-drop-in { outline: 2px solid var(--accent); outline-offset: -2px; }
body.st-listdrag, body.st-listdrag * { cursor: grabbing !important; }
.st-otools { display: flex; align-items: center; gap: 4px; margin: 4px 0 2px; font-size: 12px; }
.st-otools button { padding: 2px 7px; font-size: 12px; }
.st-otools span { margin-left: auto; color: var(--mute); cursor: help; }
.st-centre { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.st-centre > #view { flex: 1 1 auto; min-height: 0; }
+236 -17
View File
@@ -26,8 +26,8 @@
function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; }
function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; }
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
function label(o) { var m = prefab(o.type); return o.name || (o.type === "group" ? "Group" : m ? m.label : o.type); }
function icon(kind) { var m = prefab(kind); return kind === "group" ? "▤" : (m && m.icon) || ICON_DEFAULT; }
function r3(v) { return Math.round(v * 1000) / 1000; }
// ------------------------------------------------------------ storeys, frames, mounts
@@ -407,7 +407,7 @@
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, group: set, carry: [] };
return true;
}
if (!o || o.locked) return false;
if (!o || lockedEff(o)) return false;
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) };
@@ -600,6 +600,7 @@
var ids = selIds();
S.view.selectObj(ids.length > 1 ? ids : S.sel);
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); });
otools();
if (!quiet) renderRight();
}
function select(id, quiet) {
@@ -645,7 +646,7 @@
select(id);
}
function selectAll() {
selectMany((S.doc.objects || []).filter(function (o) { return !o.locked && !o.hidden; }).map(function (o) { return o.id; }));
selectMany((S.doc.objects || []).filter(function (o) { return !lockedEff(o) && !hiddenEff(o) && !isGroup(o); }).map(function (o) { return o.id; }));
}
// the selection box drawn with Shift / Ctrl + drag
function band(r) {
@@ -662,7 +663,7 @@
if (!mv.moved) { if (d.hit) toggleSel(d.hit); return; }
// every object drawn wholly inside the box (not locked or hidden)
var r = bandRect(d, e), found = (S.doc.objects || []).filter(function (o) {
if (o.locked || o.hidden) return false;
if (lockedEff(o) || hiddenEff(o) || isGroup(o)) return false;
var els = moveEls(o.id), u = null;
Array.prototype.forEach.call(els, function (el) {
var b = el.getBoundingClientRect();
@@ -677,7 +678,7 @@
}
// what a group move takes along: the selected objects that are not locked, and what sits on them
function moveSet(ids) {
var objs = ids.map(obj).filter(function (o) { return o && !o.locked; }), seen = {}, carry = [];
var objs = ids.map(obj).filter(function (o) { return o && !lockedEff(o) && !isGroup(o); }), seen = {}, carry = [];
objs.forEach(function (o) { seen[o.id] = 1; });
objs.forEach(function (o) { mountedOn(o).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = 1; carry.push(c); } }); });
return { objs: objs, carry: carry };
@@ -708,6 +709,7 @@
});
}
function duplicateMany(ids) {
ids = ids.filter(function (id) { return !isGroup(obj(id)); });
var dd = Math.max(snapStep(), 1) * 2, extra = extraOn(ids);
opList(extra.concat(ids).map(function (id) { return { op: "duplicate", id: id, dx: dd, dy: dd }; })).then(function (r) {
if (r && r.ids) selectMany(r.ids.slice(-ids.length));
@@ -724,6 +726,127 @@
var y0 = Math.min.apply(null, bs.map(function (b) { return b[1]; })), y1 = Math.max.apply(null, bs.map(function (b) { return b[4]; }));
S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2);
}
// ------------------------------------------------------------ groups and the drawing order
// A group is an object of type "group" that draws nothing; what is in it carries its id in
// `group` (groups nest). The object list is a tree built from the document order: a group's
// members lie just before the group object, so a group draws as one block at its place, and
// ground pieces (areas, beds, ponds, roads) later in the order lie on top. The list shows the
// front first. Every change to the tree is one op batch: group changes and the whole order.
var GROUP = "group", ICON_GROUP = "▤", openGroups = {}, closedGroups = {};
function isGroup(o) { return !!o && o.type === GROUP; }
function parentOf(o) { var g = o && o.group ? obj(o.group) : null; return isGroup(g) && g !== o ? g : null; }
function ancestors(o) { var out = [], g = parentOf(o); while (g && out.length < 64 && out.indexOf(g) < 0) { out.push(g); g = parentOf(g); } return out; }
function hiddenEff(o) { return !!o && (!!o.hidden || ancestors(o).some(function (g) { return g.hidden; })); }
function lockedEff(o) { return !!o && (!!o.locked || ancestors(o).some(function (g) { return g.locked; })); }
// what is in group gid: its direct members, or (deep) everything below it, groups included
function membersOf(gid, deep) {
var out = [], todo = [gid];
while (todo.length) {
var g = todo.shift();
(S.doc.objects || []).forEach(function (o) {
if (o.group === g && out.indexOf(o) < 0 && o.id !== gid) { out.push(o); if (deep && isGroup(o)) todo.push(o.id); }
});
}
return out;
}
// the tree, back to front: [{ o, kids }]; a group cycle (only by hand-edited files) stays at the root
function tree() {
var byId = {}, roots = [];
(S.doc.objects || []).forEach(function (o) { byId[o.id] = { o: o, kids: [] }; });
(S.doc.objects || []).forEach(function (o) {
var p = parentOf(o), cyc = p && ancestors(o).indexOf(o) >= 0;
(p && !cyc && byId[p.id] ? byId[p.id].kids : roots).push(byId[o.id]);
});
return roots;
}
// the document order of a tree: each group after what is in it
function flatten(nodes, out) { nodes.forEach(function (n) { flatten(n.kids, out); out.push(n.o.id); }); return out; }
// where a node is: { node, list (its siblings), parent (group id or "") }
function findNode(nodes, id, parent) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].o.id === id) return { node: nodes[i], list: nodes, parent: parent || "" };
var f = findNode(nodes[i].kids, id, nodes[i].o.id);
if (f) return f;
}
return null;
}
// the selected objects that are not inside another selected group
function topSel(ids) {
ids = (ids || selIds()).filter(function (id) { return obj(id); });
return ids.filter(function (id) { return !ancestors(obj(id)).some(function (g) { return ids.indexOf(g.id) >= 0; }); });
}
function docIndex(id) { return (S.doc.objects || []).map(function (o) { return o.id; }).indexOf(id); }
// send a changed tree: first `pre` (adds, removes), then group changes, then the order
function commitTree(roots, changes, pre, msg) {
var ops = (pre || []).concat(changes || []), ids = flatten(roots, []);
var cur = (S.doc.objects || []).map(function (o) { return o.id; }).filter(function (id) { return ids.indexOf(id) >= 0 || (pre || []).every(function (p) { return p.op !== "remove" || p.id !== id; }); });
if (ids.join("\n") !== cur.join("\n")) ops.push({ op: "order", ids: ids });
if (!ops.length) return Promise.resolve(null);
return op({ ops: ops }).then(function (r) { if (r && msg) toast(msg); return r; });
}
function groupSelection() {
var ids = topSel();
if (ids.length < 2) { toast("Select two or more objects to group them (Ctrl+click in the list or on the map)"); return; }
var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean);
locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); });
var front = locs[locs.length - 1], k = 1;
while (obj("group-" + k)) k++;
var gid = "group-" + k, n = (S.doc.objects || []).filter(isGroup).length + 1;
var gnode = { o: { id: gid, type: GROUP, group: front.parent }, kids: [] };
// the group takes the place of the front-most of them, in its parent
front.list.splice(front.list.indexOf(front.node) + 1, 0, gnode);
locs.forEach(function (l) { l.list.splice(l.list.indexOf(l.node), 1); gnode.kids.push(l.node); });
var changes = locs.map(function (l) { return { op: "update", id: l.node.o.id, patch: { group: gid } }; });
commitTree(roots, changes, [{ op: "add", type: GROUP, id: gid, name: "Group " + n, group: front.parent }], "Grouped " + locs.length + " · Ctrl+Shift+G ungroups").then(function (r) { if (r) selectGroup(gid); });
}
// the group the selection is or is in
function selGroup() {
var o = S.sel ? obj(S.sel) : null;
return isGroup(o) ? o : parentOf(o);
}
function ungroup(g) {
g = g || selGroup();
if (!g) { toast("Select a group (or something in one) to ungroup it"); return; }
var roots = tree(), loc = findNode(roots, g.id), kids = loc.node.kids;
Array.prototype.splice.apply(loc.list, [loc.list.indexOf(loc.node), 1].concat(kids));
// removing a group moves what was in it up into its parent (ops.rs)
commitTree(roots, [], [{ op: "remove", id: g.id }], "Ungrouped " + label(g) + ": " + kids.length + " object" + (kids.length === 1 ? "" : "s") + " · Ctrl+Z groups them again").then(function (r) {
if (r) selectMany(kids.map(function (n) { return n.o.id; }));
});
}
function selectGroup(gid) { selectMany([gid].concat(membersOf(gid, true).map(function (o) { return o.id; })), gid); }
// forward / backward one place among their siblings, or to the front / back of them
function moveOrder(how, ids) {
ids = topSel(ids);
if (!ids.length) return;
var roots = tree(), lists = [];
ids.forEach(function (id) { var l = findNode(roots, id); if (l && lists.indexOf(l.list) < 0) lists.push(l.list); });
lists.forEach(function (list) {
var sel = list.filter(function (n) { return ids.indexOf(n.o.id) >= 0; }), rest = list.filter(function (n) { return ids.indexOf(n.o.id) < 0; }), i;
if (how === "front" || how === "back") { list.length = 0; Array.prototype.push.apply(list, how === "front" ? rest.concat(sel) : sel.concat(rest)); return; }
if (how === "forward") { for (i = list.length - 2; i >= 0; i--) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i + 1]) < 0) { var a = list[i]; list[i] = list[i + 1]; list[i + 1] = a; } }
else { for (i = 1; i < list.length; i++) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i - 1]) < 0) { var b = list[i]; list[i] = list[i - 1]; list[i - 1] = b; } }
});
var words = { front: "to the front", back: "to the back", forward: "forward", backward: "backward" };
commitTree(roots, [], [], null).then(function (r) { if (r) toast("Moved " + words[how] + " · higher in the list draws on top (ground pieces)"); });
}
// rows dropped above (in front of), below (behind) or into a group row
function dropRows(ids, t) {
ids = topSel(ids);
var tgt = obj(t.id);
if (!tgt || ids.indexOf(t.id) >= 0) return;
if (ids.some(function (id) { return ancestors(tgt).some(function (g) { return g.id === id; }); })) { toast("A group cannot go into itself", true); return; }
var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean);
locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); });
var nodes = locs.map(function (l) { l.list.splice(l.list.indexOf(l.node), 1); return l.node; });
var dest, at, parent;
if (t.where === "into") { var g = findNode(roots, t.id); dest = g.node.kids; at = dest.length; parent = t.id; }
else { var l = findNode(roots, t.id); dest = l.list; parent = l.parent; at = dest.indexOf(l.node) + (t.where === "above" ? 1 : 0); }
Array.prototype.splice.apply(dest, [at, 0].concat(nodes));
var changes = nodes.filter(function (n) { return (n.o.group || "") !== parent; }).map(function (n) { return { op: "update", id: n.o.id, patch: { group: parent } }; });
commitTree(roots, changes, [], t.where === "into" ? "Moved into " + label(tgt) : null);
}
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
function remove(id) {
@@ -757,7 +880,7 @@
}
function nudge(id, dx, dy) {
var o = obj(id), many = selIds().length > 1;
if (!o || (o.locked && !many)) return;
if (!o || (lockedEff(o) && !many)) return;
var s = snapStep() || 0.5;
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
var rot = S.view.getRotation();
@@ -781,6 +904,10 @@
if (ctrl && (k === "a" || k === "A")) { selectAll(); return true; }
if (!S.sel) return false;
var many = selIds().length > 1;
// groups and the drawing order: Ctrl+G, Ctrl+Shift+G; Ctrl+] / Ctrl+[ forward / backward, with Shift to the front / back
if (ctrl && (k === "g" || k === "G")) { if (e.shiftKey) ungroup(); else groupSelection(); return true; }
if (ctrl && (k === "]" || k === "}")) { moveOrder(e.shiftKey ? "front" : "forward"); return true; }
if (ctrl && (k === "[" || k === "{")) { moveOrder(e.shiftKey ? "back" : "backward"); return true; }
if (k === "Delete" || k === "Backspace") { if (many) removeMany(selIds()); else remove(S.sel); return true; }
if (ctrl && (k === "d" || k === "D")) { if (many) duplicateMany(selIds()); else duplicate(S.sel); return true; }
if (!ctrl && (k === "r" || k === "R")) { if (many) turnGroup(selIds(), e.shiftKey ? -90 : 90); else turn(S.sel, e.shiftKey ? -90 : 90); return true; }
@@ -815,14 +942,38 @@
}).join("") + "</div></div>";
}).join("") || '<div class="st-help">No object matches.</div>';
}
// the object tree, front first: groups fold open and shut, every row has a hide and a lock toggle
function renderLists() {
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [];
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [], out = [];
$("ocount").textContent = list.length;
$("objects").innerHTML = list.filter(function (o) {
return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0;
}).slice().reverse().map(function (o) {
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
}).join("");
function hit(o) { return (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0; }
function shows(n) { return !q || hit(n.o) || n.kids.some(shows); }
function walk(nodes, depth) {
for (var i = nodes.length - 1; i >= 0; i--) {
var n = nodes[i], o = n.o, g = isGroup(o);
if (!shows(n)) continue;
// groups start open; a filter opens every group that holds a match
var open = g && (q ? true : !closedGroups[o.id]);
var cls = (g ? "st-group" : "") + (o.hidden ? " st-hidden" : hiddenEff(o) ? " st-hidden st-inh" : "") + (lockedEff(o) ? " st-locked" : "");
out.push('<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '" class="' + cls.trim() + '" style="--d:' + depth + '" title="' + esc(o.id) + '">' +
(g ? '<b class="st-tw" data-tw title="' + (open ? "Fold" : "Unfold") + '">' + (open ? "▾" : "▸") + "</b>" : '<b class="st-tw"></b>') +
"<i>" + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span>" +
(g ? "<small>" + membersOf(o.id, true).filter(function (x) { return !isGroup(x); }).length + "</small>" : "") +
'<button type="button" class="st-eye' + (o.hidden ? " st-on" : "") + '" data-eye title="' + (o.hidden ? "Show" : "Hide") + '">' + (o.hidden ? "◌" : "◉") + "</button>" +
'<button type="button" class="st-lk' + (o.locked ? " st-on" : "") + '" data-lk title="' + (o.locked ? "Unlock" : "Lock") + '">' + (o.locked ? "🔒" : "🔓") + "</button></li>");
if (g && open) walk(n.kids, depth + 1);
}
}
walk(tree(), 0);
$("objects").innerHTML = out.join("");
otools();
}
// the list's tool strip follows the selection
function otools() {
var sel = selIds();
$("ogroup").disabled = topSel(sel).length < 2;
$("oungroup").disabled = !selGroup();
$("ofwd").disabled = $("obwd").disabled = !sel.length;
}
// closed palette categories, remembered in this browser
function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } }
@@ -844,16 +995,59 @@
$("osearch").addEventListener("input", renderLists);
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
$("objects").addEventListener("click", function (e) {
if (listDragged) { listDragged = false; return; }
var li = e.target.closest("li"); if (!li) return;
var id = li.getAttribute("data-id");
var id = li.getAttribute("data-id"), o = obj(id);
if (!o) return;
if (e.target.closest("[data-tw]")) { if (closedGroups[id]) delete closedGroups[id]; else closedGroups[id] = 1; renderLists(); return; }
if (e.target.closest("[data-eye]")) { update(id, { hidden: !o.hidden }); return; }
if (e.target.closest("[data-lk]")) { update(id, { locked: !o.locked }); return; }
if (e.ctrlKey || e.metaKey) { toggleSel(id); return; }
if (e.shiftKey && S.sel) {
var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id);
if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; }
}
select(id);
// a group row selects the group and everything in it, so a drag on the map moves it all
if (isGroup(o)) selectGroup(id); else select(id);
});
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
$("objects").addEventListener("dblclick", function (e) {
var li = e.target.closest("li"); if (!li || e.target.closest("button,[data-tw]")) return;
var id = li.getAttribute("data-id");
if (isGroup(obj(id))) focusMany(membersOf(id, true).map(function (o) { return o.id; })); else S.view.focus(id, 3);
});
// rows dragged in the list: above or below another row (in front of or behind it), or into a
// group (the middle of its row); the selected rows go together when the one dragged is selected
var listDrag = null, listDragged = false;
function clearDrop() { Array.prototype.forEach.call(document.querySelectorAll("#objects .st-drop-above, #objects .st-drop-below, #objects .st-drop-in"), function (x) { x.classList.remove("st-drop-above", "st-drop-below", "st-drop-in"); }); }
$("objects").addEventListener("pointerdown", function (e) {
var li = e.target.closest("li");
if (e.button !== 0 || !li || e.target.closest("button,[data-tw]") || e.shiftKey || e.ctrlKey || e.metaKey) return;
listDrag = { id: li.getAttribute("data-id"), y0: e.clientY, on: false, t: null };
});
document.addEventListener("pointermove", function (e) {
var d = listDrag; if (!d) return;
if (!d.on && Math.abs(e.clientY - d.y0) < 6) return;
d.on = true; document.body.classList.add("st-listdrag");
clearDrop();
var el = document.elementFromPoint(e.clientX, e.clientY), li = el && el.closest ? el.closest("#objects li") : null;
if (!li || li.getAttribute("data-id") === d.id) { d.t = null; return; }
var r = li.getBoundingClientRect(), f = (e.clientY - r.top) / r.height, g = li.classList.contains("st-group");
var where = g && f > 0.28 && f < 0.72 ? "into" : f < 0.5 ? "above" : "below";
li.classList.add(where === "into" ? "st-drop-in" : "st-drop-" + where);
d.t = { id: li.getAttribute("data-id"), where: where };
});
document.addEventListener("pointerup", function () {
var d = listDrag; listDrag = null;
if (!d) return;
document.body.classList.remove("st-listdrag"); clearDrop();
if (!d.on) return;
listDragged = true; setTimeout(function () { listDragged = false; }, 0);
if (d.t) dropRows(selIds().indexOf(d.id) >= 0 ? selIds() : [d.id], d.t);
});
$("ogroup").addEventListener("click", function () { groupSelection(); });
$("oungroup").addEventListener("click", function () { ungroup(); });
$("ofwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "front" : "forward"); });
$("obwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "back" : "backward"); });
// ------------------------------------------------------------ right panel
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
@@ -877,6 +1071,7 @@
function renderRight() {
var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds();
if (!o) { renderProject(R); return; }
if (isGroup(o)) { renderGroup(R, o); return; }
if (ids.length > 1) { renderMany(R, ids); return; }
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
var levels = S.meta.levels || [];
@@ -909,6 +1104,21 @@
R.innerHTML = h;
}
// several objects: what they are, actions for all, and the properties when they are all one type
function renderGroup(R, g) {
var kids = membersOf(g.id, false), all = membersOf(g.id, true).filter(function (o) { return !isGroup(o); });
R.innerHTML = "<h3><i>" + ICON_GROUP + "</i>" + esc(label(g)) + "</h3>" +
'<div class="st-sub">Group · ' + kids.length + " inside, " + all.length + " object" + (all.length === 1 ? "" : "s") + " in all · id " + esc(g.id) + "</div>" +
'<p class="st-help">Everything in the group is selected: drag one of them on the map to move them all, R turns them, arrow keys nudge. Hiding or locking the group hides or locks all of it. In the list, drag rows into or out of it; higher in the list draws on top (ground pieces).</p>' +
'<div class="st-actions">' +
'<button type="button" data-act="focus">Zoom to</button>' +
'<button type="button" data-act="ungroup" title="Ctrl+Shift+G">Ungroup</button>' +
'<button type="button" data-act="front" title="Ctrl+Shift+]">To front</button>' +
'<button type="button" data-act="back" title="Ctrl+Shift+[">To back</button>' +
'<button type="button" data-act="lock">' + (g.locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (g.hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger">Delete with contents</button></div>' +
'<h2>Group</h2><div class="st-form">' + field("name", "Name", '<input data-f="name" value="' + esc(g.name || "") + '" placeholder="Group">') + "</div>";
}
function renderMany(R, ids) {
var os = ids.map(obj), count = {}, order = [];
os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; });
@@ -950,6 +1160,15 @@
$("right").addEventListener("click", function (e) {
var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return;
var a = b.getAttribute("data-act"), o = obj(S.sel), ids = selIds();
if (isGroup(o)) {
if (a === "focus") focusMany(membersOf(o.id, true).map(function (x) { return x.id; }));
else if (a === "ungroup") ungroup(o);
else if (a === "front" || a === "back") moveOrder(a, [o.id]);
else if (a === "lock") update(o.id, { locked: !o.locked });
else if (a === "hide") update(o.id, { hidden: !o.hidden });
else if (a === "del") removeMany([o.id].concat(membersOf(o.id, true).map(function (x) { return x.id; })));
return;
}
if (ids.length > 1) {
if (a === "del") removeMany(ids);
else if (a === "dup") duplicateMany(ids);
@@ -962,7 +1181,7 @@
if (a === "del") remove(S.sel);
else if (a === "dup") duplicate(S.sel);
else if (a === "focus") S.view.focus(S.sel, 3);
else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a });
else if (a === "front" || a === "back") moveOrder(a, [S.sel]);
else if (a === "lock") update(S.sel, { locked: !o.locked });
else if (a === "hide") update(S.sel, { hidden: !o.hidden });
else if (a === "rotl") turn(S.sel, -90);