From 842ac4d67ecc4f6504eff2e08fd8b9220e6e015c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 22 Mar 2026 12:44:49 -0700 Subject: [PATCH] feat(canvas): eraser works on all shapes, add lock icon for shapes - Eraser now targets any folk-shape (not just wb-drawing), erasing whatever it touches - Added lock property to folk-shape: locked shapes can't be moved, resized, or erased - Lock state persisted via Automerge sync (toJSON/applyData/fromData) - Lock icon appears beside calendar icon when shape(s) selected, toggles lock on click Co-Authored-By: Claude Opus 4.6 --- lib/folk-shape.ts | 23 ++++++++++++++ website/canvas.html | 74 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 7be037f..faeaa56 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -114,6 +114,10 @@ const styles = css` transition: opacity 0.3s ease, filter 0.3s ease; } + :host(:state(locked)) { + cursor: default; + } + [part="forgotten-tooltip"] { display: none; position: absolute; @@ -319,6 +323,18 @@ export class FolkShape extends FolkElement { : this.#internals.states.delete("forgotten"); } + #locked = false; + get locked() { + return this.#locked; + } + set locked(locked) { + if (this.#locked === locked) return; + this.#locked = locked; + locked + ? this.#internals.states.add("locked") + : this.#internals.states.delete("locked"); + } + #editing = false; get editing() { return this.#editing; @@ -470,6 +486,9 @@ export class FolkShape extends FolkElement { // In feed mode, suppress all drag/resize interactions if (this.closest('#canvas.feed-mode')) return; + // Locked shapes cannot be moved or resized, but can still be interacted with + if (this.#locked) return; + // Handle touch events for mobile drag support if (event instanceof TouchEvent) { const target = event.composedPath()[0] as HTMLElement; @@ -871,6 +890,8 @@ export class FolkShape extends FolkElement { rotation: this.rotation, }; + if (this.#locked) json.locked = true; + // Include port values when ports have data if (this.#ports.size > 0) { const portValues: Record = {}; @@ -907,6 +928,7 @@ export class FolkShape extends FolkElement { if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) { shape.rotation = data.rotation; } + if (data.locked) shape.locked = true; return shape; } @@ -921,6 +943,7 @@ export class FolkShape extends FolkElement { if (this.width !== data.width) this.width = data.width; if (this.height !== data.height) this.height = data.height; if (this.rotation !== data.rotation) this.rotation = data.rotation; + if (!!data.locked !== this.#locked) this.locked = !!data.locked; // Restore port values without dispatching events (avoids sync loops) if (data.ports && typeof data.ports === "object") { diff --git a/website/canvas.html b/website/canvas.html index 362a382..eb62edb 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2411,6 +2411,25 @@ .shape-schedule-icon:hover { background: #2a2a3e; border-color: #818cf8; } + .shape-lock-icon { + position: fixed; z-index: 9999; width: 28px; height: 28px; + border-radius: 50%; border: 1px solid #444; + background: var(--rs-bg-surface, #1e1e2e); color: #e0e0e0; + font-size: 14px; cursor: pointer; display: flex; + align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + opacity: 0; transition: opacity 0.15s; pointer-events: none; + padding: 0; line-height: 1; + } + .shape-lock-icon.visible { + opacity: 1; pointer-events: auto; + } + .shape-lock-icon:hover { + background: #2a2a3e; border-color: #818cf8; + } + .shape-lock-icon.locked { + border-color: #f59e0b; color: #f59e0b; + }
🔔 Remind me of this on:
@@ -3156,6 +3175,7 @@ } __miCanvasBridge.setSelection([...selectedShapeIds]); updateScheduleIcon(); + updateLockIcon(); } // ── Floating schedule icon on selected shape ── @@ -3201,6 +3221,51 @@ if (rwWidget) rwWidget.classList.remove("visible"); } + // ── Floating lock icon on selected shape ── + let lockIconEl = null; + function updateLockIcon() { + if (selectedShapeIds.size >= 1) { + const id = [...selectedShapeIds][0]; + const el = document.getElementById(id); + if (el) { + if (!lockIconEl) { + lockIconEl = document.createElement("button"); + lockIconEl.className = "shape-lock-icon"; + lockIconEl.textContent = "🔒"; + lockIconEl.title = "Lock/unlock shape"; + lockIconEl.addEventListener("click", (ev) => { + ev.stopPropagation(); + // Toggle lock on all selected shapes + const ids = [...selectedShapeIds]; + const firstEl = document.getElementById(ids[0]); + const newLocked = !firstEl?.locked; + for (const sid of ids) { + const sel = document.getElementById(sid); + if (sel) { + sel.locked = newLocked; + // Trigger sync update + sel.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + } + } + lockIconEl.textContent = newLocked ? "🔒" : "🔓"; + lockIconEl.classList.toggle("locked", newLocked); + }); + document.body.appendChild(lockIconEl); + } + const rect = el.getBoundingClientRect(); + lockIconEl.style.left = (rect.right + 4) + "px"; + lockIconEl.style.top = (rect.top + 28) + "px"; // below schedule icon + lockIconEl.classList.add("visible"); + // Reflect current lock state + const isLocked = el.locked; + lockIconEl.textContent = isLocked ? "🔒" : "🔓"; + lockIconEl.classList.toggle("locked", isLocked); + return; + } + } + if (lockIconEl) lockIconEl.classList.remove("visible"); + } + function rectsOverlapScreen(sel, r) { return !(sel.left > r.right || sel.right < r.left || sel.top > r.bottom || sel.bottom < r.top); @@ -4809,7 +4874,7 @@ } // Disable shape interaction when drawing tools are active. - // Eraser keeps canvasContent interactive so it can delete wb-drawing shapes. + // Eraser keeps canvasContent interactive so it can erase any shape. if (wbTool && wbTool !== "eraser") { canvasContent.style.pointerEvents = "none"; wbOverlay.style.pointerEvents = "all"; @@ -5224,12 +5289,14 @@ } }); - // Eraser for wb-drawing folk-shapes — capturing phase intercepts + // Eraser for any folk-shape — capturing phase intercepts // before normal shape interaction (drag/select) kicks in canvasContent.addEventListener("pointerdown", (e) => { if (wbTool !== "eraser") return; - const target = e.target.closest?.("[data-wb-drawing]"); + const target = e.target.closest?.("folk-shape"); if (!target) return; + // Locked shapes cannot be erased + if (target.locked) return; e.stopPropagation(); e.preventDefault(); const state = sync.getShapeVisualState(target.id); @@ -5891,6 +5958,7 @@ // Update remote cursors to match new camera position presence.setCamera(panX, panY, scale); updateScheduleIcon(); + updateLockIcon(); } // Re-render canvas background when user changes preference