feat(canvas): folk-shape and canvas.html updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-22 12:44:49 -07:00
parent 0d8d42c49e
commit 851cc283ee
2 changed files with 94 additions and 3 deletions

View File

@ -114,6 +114,10 @@ const styles = css`
transition: opacity 0.3s ease, filter 0.3s ease; transition: opacity 0.3s ease, filter 0.3s ease;
} }
:host(:state(locked)) {
cursor: default;
}
[part="forgotten-tooltip"] { [part="forgotten-tooltip"] {
display: none; display: none;
position: absolute; position: absolute;
@ -319,6 +323,18 @@ export class FolkShape extends FolkElement {
: this.#internals.states.delete("forgotten"); : 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; #editing = false;
get editing() { get editing() {
return this.#editing; return this.#editing;
@ -470,6 +486,9 @@ export class FolkShape extends FolkElement {
// In feed mode, suppress all drag/resize interactions // In feed mode, suppress all drag/resize interactions
if (this.closest('#canvas.feed-mode')) return; 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 // Handle touch events for mobile drag support
if (event instanceof TouchEvent) { if (event instanceof TouchEvent) {
const target = event.composedPath()[0] as HTMLElement; const target = event.composedPath()[0] as HTMLElement;
@ -871,6 +890,8 @@ export class FolkShape extends FolkElement {
rotation: this.rotation, rotation: this.rotation,
}; };
if (this.#locked) json.locked = true;
// Include port values when ports have data // Include port values when ports have data
if (this.#ports.size > 0) { if (this.#ports.size > 0) {
const portValues: Record<string, unknown> = {}; const portValues: Record<string, unknown> = {};
@ -907,6 +928,7 @@ export class FolkShape extends FolkElement {
if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) { if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) {
shape.rotation = data.rotation; shape.rotation = data.rotation;
} }
if (data.locked) shape.locked = true;
return shape; return shape;
} }
@ -921,6 +943,7 @@ export class FolkShape extends FolkElement {
if (this.width !== data.width) this.width = data.width; if (this.width !== data.width) this.width = data.width;
if (this.height !== data.height) this.height = data.height; if (this.height !== data.height) this.height = data.height;
if (this.rotation !== data.rotation) this.rotation = data.rotation; 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) // Restore port values without dispatching events (avoids sync loops)
if (data.ports && typeof data.ports === "object") { if (data.ports && typeof data.ports === "object") {

View File

@ -2411,6 +2411,25 @@
.shape-schedule-icon:hover { .shape-schedule-icon:hover {
background: #2a2a3e; border-color: #818cf8; 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;
}
</style> </style>
<div id="reminder-widget"> <div id="reminder-widget">
<div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div> <div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div>
@ -3156,6 +3175,7 @@
} }
__miCanvasBridge.setSelection([...selectedShapeIds]); __miCanvasBridge.setSelection([...selectedShapeIds]);
updateScheduleIcon(); updateScheduleIcon();
updateLockIcon();
} }
// ── Floating schedule icon on selected shape ── // ── Floating schedule icon on selected shape ──
@ -3201,6 +3221,51 @@
if (rwWidget) rwWidget.classList.remove("visible"); 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) { function rectsOverlapScreen(sel, r) {
return !(sel.left > r.right || sel.right < r.left || return !(sel.left > r.right || sel.right < r.left ||
sel.top > r.bottom || sel.bottom < r.top); sel.top > r.bottom || sel.bottom < r.top);
@ -4809,7 +4874,7 @@
} }
// Disable shape interaction when drawing tools are active. // 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") { if (wbTool && wbTool !== "eraser") {
canvasContent.style.pointerEvents = "none"; canvasContent.style.pointerEvents = "none";
wbOverlay.style.pointerEvents = "all"; 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 // before normal shape interaction (drag/select) kicks in
canvasContent.addEventListener("pointerdown", (e) => { canvasContent.addEventListener("pointerdown", (e) => {
if (wbTool !== "eraser") return; if (wbTool !== "eraser") return;
const target = e.target.closest?.("[data-wb-drawing]"); const target = e.target.closest?.("folk-shape");
if (!target) return; if (!target) return;
// Locked shapes cannot be erased
if (target.locked) return;
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const state = sync.getShapeVisualState(target.id); const state = sync.getShapeVisualState(target.id);
@ -5891,6 +5958,7 @@
// Update remote cursors to match new camera position // Update remote cursors to match new camera position
presence.setCamera(panX, panY, scale); presence.setCamera(panX, panY, scale);
updateScheduleIcon(); updateScheduleIcon();
updateLockIcon();
} }
// Re-render canvas background when user changes preference // Re-render canvas background when user changes preference