Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 658eb966d6 fix: push overlapping siblings instead of displacing the dragged shape
The overlap resolver now moves siblings in the drag direction rather
than snapping the dragged shape away from them. Supports chain-pushing
(A pushes B into C) with a recursion depth of 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:51:34 -08:00
Jeff Emmett 7db6068229 feat: add forgotten-shape tooltip and "Hide Faded" toggle
Hovering a forgotten shape now shows a tooltip explaining the state.
A new "Hide Faded" toolbar button lets users hide all forgotten shapes
entirely, with the preference persisted in localStorage. Hidden shapes
reappear automatically when another user remembers them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:05:20 -08:00
2 changed files with 78 additions and 23 deletions

View File

@ -112,6 +112,25 @@ const styles = css`
transition: opacity 0.3s ease, filter 0.3s ease; transition: opacity 0.3s ease, filter 0.3s ease;
} }
[part="forgotten-tooltip"] {
display: none;
position: absolute;
top: -32px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.85);
color: #fff;
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 9999;
}
:host(:state(forgotten)):hover [part="forgotten-tooltip"] {
display: block;
}
:host(:state(move)), :host(:state(move)),
:host(:state(rotate)), :host(:state(rotate)),
:host(:state(resize-top-left)), :host(:state(resize-top-left)),
@ -351,7 +370,8 @@ export class FolkShape extends FolkElement {
<button part="resize-top-right" aria-label="Resize shape from top right"></button> <button part="resize-top-right" aria-label="Resize shape from top right"></button>
<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button> <button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button> <button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
<div class="slot-container"><slot></slot></div>`, <div class="slot-container"><slot></slot></div>
<div part="forgotten-tooltip">Forgotten right-click to remember or delete</div>`,
); );
this.#handles = Object.fromEntries( this.#handles = Object.fromEntries(
@ -705,19 +725,29 @@ export class FolkShape extends FolkElement {
} }
/** /**
* After moving, push this shape away from any overlapping siblings. * After moving, push overlapping siblings out of the way.
* Uses minimum penetration depth picks the smallest displacement * The dragged shape stays where the cursor placed it;
* among all four directions to resolve each overlap. * siblings are displaced in the drag direction.
*/ */
#resolveOverlaps(_dx: number, _dy: number) { #resolveOverlaps(dx: number, dy: number) {
const parent = this.parentElement; const parent = this.parentElement;
if (!parent) return; if (!parent) return;
this.#pushSiblings(parent, dx, dy, new Set<FolkShape>([this]), 3);
}
/**
* For each sibling overlapping `pusher`, move the sibling out of the way.
* Recurses so chain-pushing works (A pushes B into C C also moves).
*/
#pushSiblings(parent: Element, dx: number, dy: number, excluded: Set<FolkShape>, depth: number) {
if (depth <= 0) return;
const gap = FolkShape.GAP; const gap = FolkShape.GAP;
const me = { x: this.x, y: this.y, w: this.width, h: this.height }; const me = { x: this.x, y: this.y, w: this.width, h: this.height };
for (const sibling of parent.children) { for (const sibling of parent.children) {
if (sibling === this || !(sibling instanceof FolkShape)) continue; if (!(sibling instanceof FolkShape)) continue;
if (excluded.has(sibling)) continue;
if (sibling.tagName.toLowerCase() === "folk-arrow") continue; if (sibling.tagName.toLowerCase() === "folk-arrow") continue;
const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height }; const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height };
@ -725,32 +755,40 @@ export class FolkShape extends FolkElement {
// Check overlap (with gap buffer) // Check overlap (with gap buffer)
const overlapX = me.x < other.x + other.w + gap && me.x + me.w + gap > other.x; 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; const overlapY = me.y < other.y + other.h + gap && me.y + me.h + gap > other.y;
if (!overlapX || !overlapY) continue; if (!overlapX || !overlapY) continue;
// Distance to clear on each side (4 possible escape directions) // How far to push the sibling in each direction to clear the overlap
const clearRight = (other.x + other.w + gap) - me.x; const pushRight = (me.x + me.w + gap) - other.x; // sibling moves +x
const clearLeft = other.x - (me.x + me.w + gap); const pushLeft = (me.x) - (other.x + other.w + gap); // sibling moves -x
const clearDown = (other.y + other.h + gap) - me.y; const pushDown = (me.y + me.h + gap) - other.y; // sibling moves +y
const clearUp = other.y - (me.y + me.h + gap); const pushUp = (me.y) - (other.y + other.h + gap); // sibling moves -y
// Pick the direction with smallest absolute displacement
const candidates = [ const candidates = [
{ axis: "x" as const, d: clearRight }, { axis: "x" as const, d: pushRight },
{ axis: "x" as const, d: clearLeft }, { axis: "x" as const, d: pushLeft },
{ axis: "y" as const, d: clearDown }, { axis: "y" as const, d: pushDown },
{ axis: "y" as const, d: clearUp }, { axis: "y" as const, d: pushUp },
]; ];
const best = candidates.reduce((a, b) => Math.abs(a.d) < Math.abs(b.d) ? a : b);
// Prefer directions aligned with drag movement, break ties by smallest displacement
const best = candidates.reduce((a, b) => {
const aAligned = (a.axis === "x" ? a.d * dx : a.d * dy) > 0;
const bAligned = (b.axis === "x" ? b.d * dx : b.d * dy) > 0;
if (aAligned !== bAligned) return aAligned ? a : b;
return Math.abs(a.d) < Math.abs(b.d) ? a : b;
});
// Push the sibling (public setters trigger requestUpdate + transform events)
if (best.axis === "x") { if (best.axis === "x") {
this.#rect.x += best.d; sibling.x += best.d;
} else { } else {
this.#rect.y += best.d; sibling.y += best.d;
} }
me.x = this.#rect.x; // Recurse: the pushed sibling may now overlap others
me.y = this.#rect.y; const nextExcluded = new Set(excluded);
nextExcluded.add(sibling);
sibling.#pushSiblings(parent, dx, dy, nextExcluded, depth - 1);
} }
} }

View File

@ -663,6 +663,10 @@
overflow: visible; overflow: visible;
} }
#canvas-content.hide-forgotten :state(forgotten) {
display: none !important;
}
#select-rect { #select-rect {
position: fixed; position: fixed;
border: 1.5px solid #3b82f6; border: 1.5px solid #3b82f6;
@ -1128,6 +1132,7 @@
<button id="new-arrow" title="Connect rSpaces">↗️ Connect</button> <button id="new-arrow" title="Connect rSpaces">↗️ Connect</button>
<button id="new-feed" title="New Feed from another layer">🔄 Feed</button> <button id="new-feed" title="New Feed from another layer">🔄 Feed</button>
<button id="toggle-memory" title="Forgotten rSpaces">💭 Memory</button> <button id="toggle-memory" title="Forgotten rSpaces">💭 Memory</button>
<button id="toggle-hide-forgotten" title="Hide forgotten items">👁 Hide Faded</button>
<button id="toggle-theme" title="Toggle dark mode">🌙 Dark</button> <button id="toggle-theme" title="Toggle dark mode">🌙 Dark</button>
<span class="toolbar-sep"></span> <span class="toolbar-sep"></span>
@ -2907,6 +2912,18 @@
if (isOpen) renderMemoryPanel(); if (isOpen) renderMemoryPanel();
}); });
// Hide Faded toggle — hides all forgotten shapes from the canvas
const hideForgottenBtn = document.getElementById("toggle-hide-forgotten");
if (localStorage.getItem("rspace_hide_forgotten") === "1") {
canvasContent.classList.add("hide-forgotten");
hideForgottenBtn.classList.add("active");
}
hideForgottenBtn.addEventListener("click", () => {
const hidden = canvasContent.classList.toggle("hide-forgotten");
hideForgottenBtn.classList.toggle("active", hidden);
localStorage.setItem("rspace_hide_forgotten", hidden ? "1" : "0");
});
// Refresh panel when shapes change state via remote sync // Refresh panel when shapes change state via remote sync
sync.addEventListener("shape-forgotten", () => { sync.addEventListener("shape-forgotten", () => {
if (memoryPanel.classList.contains("open")) renderMemoryPanel(); if (memoryPanel.classList.contains("open")) renderMemoryPanel();
@ -2977,7 +2994,7 @@
const btn = e.target.closest("button"); const btn = e.target.closest("button");
if (!btn) return; if (!btn) return;
// Keep open for connect, memory, group toggles, collapse, whiteboard tools // Keep open for connect, memory, group toggles, collapse, whiteboard tools
const keepOpen = ["new-arrow", "toggle-memory", "toggle-theme", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse", const keepOpen = ["new-arrow", "toggle-memory", "toggle-hide-forgotten", "toggle-theme", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse",
"wb-pencil", "wb-rect", "wb-circle", "wb-line", "wb-eraser"]; "wb-pencil", "wb-rect", "wb-circle", "wb-line", "wb-eraser"];
if (btn.classList.contains("toolbar-group-toggle")) return; if (btn.classList.contains("toolbar-group-toggle")) return;
if (!keepOpen.includes(btn.id)) { if (!keepOpen.includes(btn.id)) {