fix: shape overlap push-aside, coordinate persistence, toolbar panel clipping

- Collision: shapes now slide-off in movement direction by minimum
  penetration depth instead of flipping to the opposite side
- Coordinates: use nullish coalescing (??) so x=0/y=0 are preserved
  on reload instead of being replaced by falsy-check defaults
- Toolbar: remove overflow:hidden from #toolbar-panel so submenus
  render fully visible instead of being clipped/scrolled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 16:20:06 -08:00
parent 09d23f8fc1
commit eee9cbed69
2 changed files with 353 additions and 105 deletions

View File

@ -76,6 +76,16 @@ const styles = css`
pointer-events: none;
}
:host(:state(editing)) .slot-container {
pointer-events: auto;
}
:host(:state(editing)) {
outline: 2px solid #14b8a6;
outline-offset: 2px;
cursor: text;
}
::slotted(*) {
cursor: default;
pointer-events: auto;
@ -263,6 +273,37 @@ export class FolkShape extends FolkElement {
: this.#internals.states.delete("highlighted");
}
#editing = false;
get editing() {
return this.#editing;
}
enterEditMode() {
if (this.#editing) return;
this.#editing = true;
this.#internals.states.add("editing");
// Find first focusable child and focus it
const root = this.renderRoot as ShadowRoot;
const focusable = root.querySelector<HTMLElement>(
'input, textarea, [contenteditable="true"], select'
) ?? this.querySelector<HTMLElement>(
'input, textarea, [contenteditable="true"], select'
);
if (focusable) {
focusable.focus();
}
this.dispatchEvent(new CustomEvent("edit-enter"));
}
exitEditMode() {
if (!this.#editing) return;
this.#editing = false;
this.#internals.states.delete("editing");
this.dispatchEvent(new CustomEvent("edit-exit"));
}
override createRenderRoot() {
const root = super.createRenderRoot();
@ -272,6 +313,11 @@ export class FolkShape extends FolkElement {
this.addEventListener("touchend", this);
this.addEventListener("keydown", this);
this.addEventListener("dblclick", (e: MouseEvent) => {
e.stopPropagation();
this.enterEditMode();
});
(root as ShadowRoot).setHTMLUnsafe(
html`<button part="rotation-top-left" tabindex="-1"></button>
<button part="rotation-top-right" tabindex="-1"></button>
@ -609,7 +655,7 @@ export class FolkShape extends FolkElement {
/**
* After moving, push this shape away from any overlapping siblings.
* Uses the direction of the move to decide which side to slide to.
* Resolves by minimum penetration on the axis aligned with movement direction.
*/
#resolveOverlaps(dx: number, dy: number) {
const parent = this.parentElement;
@ -624,28 +670,27 @@ export class FolkShape extends FolkElement {
const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height };
// Check overlap (axis-aligned)
// Check overlap (with gap buffer)
const overlapX = me.x < other.x + other.w + gap && me.x + me.w + gap > other.x;
const overlapY = me.y < other.y + other.h + gap && me.y + me.h + gap > other.y;
if (!overlapX || !overlapY) continue;
// Compute penetration depths from each side
const pushRight = (other.x + other.w + gap) - me.x;
const pushLeft = me.x + me.w + gap - other.x;
const pushDown = (other.y + other.h + gap) - me.y;
const pushUp = me.y + me.h + gap - other.y;
// Distance to clear on each side
const clearRight = (other.x + other.w + gap) - me.x; // push me right of other
const clearLeft = other.x - (me.x + me.w + gap); // push me left of other (negative)
const clearDown = (other.y + other.h + gap) - me.y; // push me below other
const clearUp = other.y - (me.y + me.h + gap); // push me above other (negative)
// Pick the axis with the smallest penetration, biased by move direction
const minX = pushRight < pushLeft ? -pushRight : pushLeft;
const minY = pushDown < pushUp ? -pushDown : pushUp;
// Pick push direction per axis based on movement direction
const pushX = dx >= 0 ? clearRight : clearLeft;
const pushY = dy >= 0 ? clearDown : clearUp;
if (Math.abs(minX) < Math.abs(minY)) {
// Slide horizontally
this.#rect.x += dx <= 0 ? -pushLeft : pushRight;
// Apply the axis with the smallest absolute displacement
if (Math.abs(pushX) <= Math.abs(pushY)) {
this.#rect.x += pushX;
} else {
// Slide vertically
this.#rect.y += dy <= 0 ? -pushUp : pushDown;
this.#rect.y += pushY;
}
me.x = this.#rect.x;

View File

@ -105,6 +105,69 @@
color: white;
}
/* Popout panel — renders group tools to the right of toolbar */
#toolbar-panel {
position: fixed;
top: 108px;
left: calc(68px + 12px + 8px);
min-width: 180px;
max-height: calc(100vh - 130px);
background: white;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
z-index: 1001;
display: none;
flex-direction: column;
}
#toolbar-panel.panel-open {
display: flex;
}
#toolbar-panel-header {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#toolbar-panel-body {
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
#toolbar-panel-body button {
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 13px;
text-align: left;
white-space: nowrap;
transition: background 0.15s;
}
#toolbar-panel-body button:hover {
background: #f1f5f9;
}
#toolbar-panel-body button.active {
background: #14b8a6;
color: white;
}
/* Hide inline dropdowns — all rendering goes through the panel */
.toolbar-group.open > .toolbar-dropdown {
display: none !important;
}
/* Separator between sections */
.toolbar-sep {
width: 100%;
@ -586,6 +649,16 @@
#memory-panel {
max-width: calc(100vw - 32px);
}
/* Mobile: panel slides up from bottom as sheet */
#toolbar-panel {
top: auto;
bottom: 90px;
left: 8px;
right: 8px;
border-radius: 16px;
max-height: 50vh;
}
}
</style>
<link rel="stylesheet" href="/shell.css">
@ -619,6 +692,8 @@
</div>
<div id="toolbar">
<button id="quick-add" title="Add rApp" style="background:#14b8a6;color:white;font-size:18px;font-weight:700;padding:6px 10px;border:none;border-radius:8px;cursor:pointer;text-align:center;line-height:1;">+</button>
<span class="toolbar-sep"></span>
<div class="toolbar-group">
<button class="toolbar-group-toggle">✏️ Draw</button>
<div class="toolbar-dropdown">
@ -746,6 +821,11 @@
<button id="toolbar-collapse" title="Minimize toolbar">···</button>
</div>
<div id="toolbar-panel">
<div id="toolbar-panel-header"></div>
<div id="toolbar-panel-body"></div>
</div>
<div id="memory-panel">
<div id="memory-panel-header">
<h3>💭 Memory</h3>
@ -1415,10 +1495,10 @@
}
shape.id = data.id;
shape.x = data.x || 100;
shape.y = data.y || 100;
shape.width = data.width || 300;
shape.height = data.height || 200;
shape.x = data.x ?? 100;
shape.y = data.y ?? 100;
shape.width = data.width ?? 300;
shape.height = data.height ?? 200;
if (data.rotation) shape.rotation = data.rotation;
return shape;
@ -1521,11 +1601,18 @@
.map(el => ({ x: el.x, y: el.y, width: el.width, height: el.height }));
}
// Find a free position near the viewport center that doesn't overlap existing shapes
function findFreePosition(width, height) {
const center = getViewportCenter();
const candidateX = center.x - width / 2;
const candidateY = center.y - height / 2;
// Find a free position that doesn't overlap existing shapes.
// If preferX/preferY are provided, use that as the anchor; otherwise use viewport center.
function findFreePosition(width, height, preferX, preferY) {
let candidateX, candidateY;
if (preferX !== undefined && preferY !== undefined) {
candidateX = preferX - width / 2;
candidateY = preferY - height / 2;
} else {
const center = getViewportCenter();
candidateX = center.x - width / 2;
candidateY = center.y - height / 2;
}
const gap = 20;
const existing = getExistingShapeRects();
@ -1569,15 +1656,62 @@
};
}
// ── Pending tool state for click-to-place ──
let pendingTool = null; // { tagName, props }
let ghostEl = null;
function setPendingTool(tagName, props = {}) {
pendingTool = { tagName, props };
canvas.style.cursor = "crosshair";
// Create ghost outline
if (ghostEl) ghostEl.remove();
const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 };
ghostEl = document.createElement("div");
ghostEl.style.cssText = `
position: fixed; pointer-events: none; z-index: 9999;
width: ${defaults.width * scale}px; height: ${defaults.height * scale}px;
border: 2px dashed #14b8a6; border-radius: 8px;
background: rgba(20, 184, 166, 0.06);
transform: translate(-50%, -50%);
transition: width 0.1s, height 0.1s;
`;
document.body.appendChild(ghostEl);
}
function clearPendingTool() {
pendingTool = null;
canvas.style.cursor = "";
if (ghostEl) { ghostEl.remove(); ghostEl = null; }
}
// Track ghost position
document.addEventListener("mousemove", (e) => {
if (ghostEl) {
ghostEl.style.left = e.clientX + "px";
ghostEl.style.top = e.clientY + "px";
}
});
// ESC clears pending tool
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && pendingTool) {
clearPendingTool();
}
});
// Create a shape, position it without overlapping others, add to canvas, and register for sync
function newShape(tagName, props = {}) {
// atPosition: optional { x, y } in canvas coordinates to place near
function newShape(tagName, props = {}, atPosition) {
const id = `shape-${Date.now()}-${++shapeCounter}`;
const defaults = SHAPE_DEFAULTS[tagName] || { width: 300, height: 200 };
const shape = document.createElement(tagName);
shape.id = id;
const pos = findFreePosition(defaults.width, defaults.height);
const pos = atPosition
? findFreePosition(defaults.width, defaults.height, atPosition.x, atPosition.y)
: findFreePosition(defaults.width, defaults.height);
shape.x = pos.x;
shape.y = pos.y;
shape.width = defaults.width;
@ -1606,99 +1740,95 @@
window.__canvasApi = { newShape, findFreePosition, SHAPE_DEFAULTS, setupShapeEventListeners, sync, canvasContent };
installSelectionTransforms();
// Toolbar button handlers
// Toolbar button handlers — set pending tool for click-to-place
document.getElementById("new-markdown").addEventListener("click", () => {
newShape("folk-markdown", { content: "# New Note\n\nStart typing..." });
setPendingTool("folk-markdown", { content: "# New Note\n\nStart typing..." });
});
document.getElementById("new-wrapper").addEventListener("click", () => {
const colors = ["#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#22c55e"];
const icons = ["📋", "💡", "📌", "🔗", "📁", "⭐"];
const shape = newShape("folk-wrapper", {
setPendingTool("folk-wrapper", {
title: "New Card",
icon: icons[Math.floor(Math.random() * icons.length)],
primaryColor: colors[Math.floor(Math.random() * colors.length)],
__postCreate: (shape) => {
const content = document.createElement("div");
content.style.padding = "16px";
content.style.color = "#374151";
content.innerHTML = "<p>Click to edit this card...</p>";
shape.appendChild(content);
}
});
if (shape) {
const content = document.createElement("div");
content.style.padding = "16px";
content.style.color = "#374151";
content.innerHTML = "<p>Click to edit this card...</p>";
shape.appendChild(content);
}
});
document.getElementById("new-slide").addEventListener("click", () => {
newShape("folk-slide", { label: `Slide ${shapeCounter}` });
setPendingTool("folk-slide", { label: `Slide ${shapeCounter}` });
});
document.getElementById("new-chat").addEventListener("click", () => {
const id = `shape-${Date.now()}-${shapeCounter}`;
newShape("folk-chat", { roomId: `room-${id}` });
setPendingTool("folk-chat", { roomId: `room-${id}` });
});
document.getElementById("new-piano").addEventListener("click", () => newShape("folk-piano"));
document.getElementById("new-embed").addEventListener("click", () => newShape("folk-embed"));
document.getElementById("new-calendar").addEventListener("click", () => newShape("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => newShape("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => newShape("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => newShape("folk-video-gen"));
document.getElementById("new-prompt").addEventListener("click", () => newShape("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => newShape("folk-transcription"));
document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat"));
document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note"));
document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block"));
document.getElementById("new-splat").addEventListener("click", () => newShape("folk-splat"));
document.getElementById("new-blender").addEventListener("click", () => newShape("folk-blender"));
document.getElementById("new-drawfast").addEventListener("click", () => newShape("folk-drawfast"));
document.getElementById("new-freecad").addEventListener("click", () => newShape("folk-freecad"));
document.getElementById("new-kicad").addEventListener("click", () => newShape("folk-kicad"));
document.getElementById("new-piano").addEventListener("click", () => setPendingTool("folk-piano"));
document.getElementById("new-embed").addEventListener("click", () => setPendingTool("folk-embed"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription"));
document.getElementById("new-video-chat").addEventListener("click", () => setPendingTool("folk-video-chat"));
document.getElementById("new-obs-note").addEventListener("click", () => setPendingTool("folk-obs-note"));
document.getElementById("new-workflow").addEventListener("click", () => setPendingTool("folk-workflow-block"));
document.getElementById("new-splat").addEventListener("click", () => setPendingTool("folk-splat"));
document.getElementById("new-blender").addEventListener("click", () => setPendingTool("folk-blender"));
document.getElementById("new-drawfast").addEventListener("click", () => setPendingTool("folk-drawfast"));
document.getElementById("new-freecad").addEventListener("click", () => setPendingTool("folk-freecad"));
document.getElementById("new-kicad").addEventListener("click", () => setPendingTool("folk-kicad"));
document.getElementById("new-google-item").addEventListener("click", () => {
newShape("folk-google-item", { service: "drive", title: "New Google Item" });
setPendingTool("folk-google-item", { service: "drive", title: "New Google Item" });
});
// Trip planning components
document.getElementById("new-itinerary").addEventListener("click", () => newShape("folk-itinerary"));
document.getElementById("new-destination").addEventListener("click", () => newShape("folk-destination"));
document.getElementById("new-budget").addEventListener("click", () => newShape("folk-budget"));
document.getElementById("new-packing-list").addEventListener("click", () => newShape("folk-packing-list"));
document.getElementById("new-booking").addEventListener("click", () => newShape("folk-booking"));
document.getElementById("new-itinerary").addEventListener("click", () => setPendingTool("folk-itinerary"));
document.getElementById("new-destination").addEventListener("click", () => setPendingTool("folk-destination"));
document.getElementById("new-budget").addEventListener("click", () => setPendingTool("folk-budget"));
document.getElementById("new-packing-list").addEventListener("click", () => setPendingTool("folk-packing-list"));
document.getElementById("new-booking").addEventListener("click", () => setPendingTool("folk-booking"));
// Token creation - creates a mint + ledger pair with connecting arrow
document.getElementById("new-token").addEventListener("click", () => {
const mint = newShape("folk-token-mint", {
setPendingTool("folk-token-mint", {
tokenName: "New Token",
tokenSymbol: "TKN",
totalSupply: 1000,
tokenColor: "#8b5cf6",
tokenIcon: "🪙",
createdAt: new Date().toISOString(),
});
if (mint) {
const ledger = newShape("folk-token-ledger", {
mintId: mint.id,
entries: [],
});
if (ledger) {
// Position ledger to the right of mint
ledger.x = mint.x + mint.width + 60;
ledger.y = mint.y;
// Connect with an arrow
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`;
const arrow = document.createElement("folk-arrow");
arrow.id = arrowId;
arrow.sourceId = mint.id;
arrow.targetId = ledger.id;
arrow.color = "#8b5cf6";
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
__postCreate: (mint) => {
const ledger = newShape("folk-token-ledger", {
mintId: mint.id,
entries: [],
}, { x: mint.x + mint.width + 60 + 150, y: mint.y + mint.height / 2 });
if (ledger) {
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`;
const arrow = document.createElement("folk-arrow");
arrow.id = arrowId;
arrow.sourceId = mint.id;
arrow.targetId = ledger.id;
arrow.color = "#8b5cf6";
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
}
}
}
});
});
// Decision/choice components
document.getElementById("new-choice-vote").addEventListener("click", () => {
newShape("folk-choice-vote", {
setPendingTool("folk-choice-vote", {
title: "Quick Poll",
options: [
{ id: "opt-1", label: "Option A", color: "#3b82f6" },
@ -1712,7 +1842,7 @@
});
document.getElementById("new-choice-rank").addEventListener("click", () => {
newShape("folk-choice-rank", {
setPendingTool("folk-choice-rank", {
title: "Rank These",
options: [
{ id: "opt-1", label: "Option A" },
@ -1724,7 +1854,7 @@
});
document.getElementById("new-choice-spider").addEventListener("click", () => {
newShape("folk-choice-spider", {
setPendingTool("folk-choice-spider", {
title: "Evaluate Options",
options: [
{ id: "opt-1", label: "Option A" },
@ -1742,7 +1872,7 @@
// Social media post
document.getElementById("new-social-post").addEventListener("click", () => {
newShape("folk-social-post", {
setPendingTool("folk-social-post", {
platform: "x",
postType: "text",
content: "Write your post content here...",
@ -1775,7 +1905,7 @@
const btn = document.getElementById(app.btnId);
if (btn) {
btn.addEventListener("click", () => {
newShape("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug });
setPendingTool("folk-rapp", { moduleId: app.moduleId, spaceSlug: communitySlug });
});
}
}
@ -2180,8 +2310,22 @@
const toolbarEl = document.getElementById("toolbar");
mobileMenuBtn.addEventListener("click", () => {
const isOpen = toolbarEl.classList.toggle("mobile-open");
mobileMenuBtn.textContent = isOpen ? "✕" : "✚";
// On mobile, first tap opens rApps group in the popout panel
const rAppsGroup = toolbarEl.querySelector(".toolbar-group:has(#embed-notes)")
|| [...toolbarEl.querySelectorAll(".toolbar-group")].find(g =>
g.querySelector(".toolbar-group-toggle")?.textContent.includes("rApps"));
if (rAppsGroup && !toolbarEl.classList.contains("mobile-open")) {
toolbarEl.classList.add("mobile-open");
mobileMenuBtn.textContent = "✕";
// Auto-open the rApps panel
setTimeout(() => {
if (typeof openToolbarPanel === "function") openToolbarPanel(rAppsGroup);
}, 50);
} else {
const isOpen = toolbarEl.classList.toggle("mobile-open");
mobileMenuBtn.textContent = isOpen ? "✕" : "✚";
if (!isOpen && typeof closeToolbarPanel === "function") closeToolbarPanel();
}
});
// Auto-close toolbar after tapping a shape-creation button on mobile
@ -2199,30 +2343,67 @@
}
});
// Dropdown group toggles
// Popout panel references
const toolbarPanel = document.getElementById("toolbar-panel");
const toolbarPanelHeader = document.getElementById("toolbar-panel-header");
const toolbarPanelBody = document.getElementById("toolbar-panel-body");
let activeToolbarGroup = null;
function openToolbarPanel(group) {
const toggle = group.querySelector(".toolbar-group-toggle");
const dropdown = group.querySelector(".toolbar-dropdown");
if (!dropdown) return;
// Set header text from the toggle button
toolbarPanelHeader.textContent = toggle.textContent.trim();
// Clone dropdown buttons into the panel body
toolbarPanelBody.innerHTML = "";
for (const btn of dropdown.querySelectorAll("button")) {
const clone = btn.cloneNode(true);
// Forward click to the original button
clone.addEventListener("click", (e) => {
e.stopPropagation();
btn.click();
// Close panel after tool is selected (unless it's a whiteboard toggle)
const keepOpen = ["wb-pencil", "wb-rect", "wb-circle", "wb-line", "wb-eraser"];
if (!keepOpen.includes(btn.id)) {
closeToolbarPanel();
}
});
toolbarPanelBody.appendChild(clone);
}
// Mark group as active
toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open"));
group.classList.add("open");
activeToolbarGroup = group;
toolbarPanel.classList.add("panel-open");
}
function closeToolbarPanel() {
toolbarPanel.classList.remove("panel-open");
toolbarEl.querySelectorAll(".toolbar-group").forEach(g => g.classList.remove("open"));
activeToolbarGroup = null;
}
// Dropdown group toggles → popout panel
toolbarEl.querySelectorAll(".toolbar-group-toggle").forEach(toggle => {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const group = toggle.closest(".toolbar-group");
const wasOpen = group.classList.contains("open");
// Close all other groups
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
// Toggle this one
if (!wasOpen) group.classList.add("open");
if (activeToolbarGroup === group) {
closeToolbarPanel();
} else {
openToolbarPanel(group);
}
});
});
// Close dropdowns when clicking a tool inside one
toolbarEl.querySelectorAll(".toolbar-dropdown button").forEach(btn => {
btn.addEventListener("click", () => {
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
});
});
// Close dropdowns when clicking outside
// Close panel when clicking outside toolbar + panel
document.addEventListener("click", (e) => {
if (!e.target.closest("#toolbar")) {
toolbarEl.querySelectorAll(".toolbar-group.open").forEach(g => g.classList.remove("open"));
if (!e.target.closest("#toolbar") && !e.target.closest("#toolbar-panel")) {
closeToolbarPanel();
}
});
@ -2317,9 +2498,31 @@
canvas.addEventListener("pointerdown", (e) => {
if (e.target !== canvas && e.target !== canvasContent) return;
if (connectMode) return;
// Clicking canvas background clears MI selection
// Click-to-place: if a pending tool is set, place it at the click position
if (pendingTool) {
e.preventDefault();
e.stopPropagation();
const rect = canvasContent.getBoundingClientRect();
const canvasX = (e.clientX - rect.left) / scale;
const canvasY = (e.clientY - rect.top) / scale;
const { tagName, props } = pendingTool;
const postCreate = props.__postCreate;
const cleanProps = { ...props };
delete cleanProps.__postCreate;
const shape = newShape(tagName, cleanProps, { x: canvasX, y: canvasY });
if (shape && postCreate) postCreate(shape);
clearPendingTool();
return;
}
// Clicking canvas background clears MI selection and exits any editing shape
selectedShapeId = null;
__miCanvasBridge.setSelection([]);
// Exit edit mode on any currently-editing shape
canvasContent.querySelectorAll("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-workflow-block").forEach(el => {
if (el.exitEditMode) el.exitEditMode();
});
isPanning = true;
panPointerId = e.pointerId;
panStartX = e.clientX;