feat: add Copy to Space context menu for shapes

Right-click shapes (single or multi-selected) to copy them to another
space the user owns or is a member of. Server endpoint handles ID
remapping, arrow reference preservation, and position centering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 12:58:44 -08:00
parent 1f0ef59369
commit 4ebbf9f116
2 changed files with 345 additions and 16 deletions

View File

@ -27,6 +27,7 @@ import {
setMember, setMember,
removeMember, removeMember,
DEFAULT_COMMUNITY_NEST_POLICY, DEFAULT_COMMUNITY_NEST_POLICY,
addShapes,
} from "./community-store"; } from "./community-store";
import type { import type {
SpaceVisibility, SpaceVisibility,
@ -1125,4 +1126,102 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
return c.json({ error: "action must be 'approve' or 'deny'" }, 400); return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
}); });
// ══════════════════════════════════════════════════════════════════════════════
// COPY SHAPES API
// ══════════════════════════════════════════════════════════════════════════════
spaces.post("/:slug/copy-shapes", async (c) => {
const targetSlug = c.req.param("slug");
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
// Verify target space exists and user has write access
await loadCommunity(targetSlug);
const data = getDocumentData(targetSlug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const member = data.members?.[claims.sub];
if (!isOwner && !member) {
return c.json({ error: "You don't have access to this space" }, 403);
}
const body = await c.req.json<{ shapes: Record<string, unknown>[] }>();
if (!Array.isArray(body.shapes) || body.shapes.length === 0) {
return c.json({ error: "shapes array is required" }, 400);
}
const now = Date.now();
const rand4 = () => Math.random().toString(36).slice(2, 6);
// Build old-ID → new-ID map
const idMap = new Map<string, string>();
for (let i = 0; i < body.shapes.length; i++) {
const oldId = body.shapes[i].id as string;
if (oldId) {
idMap.set(oldId, `copy-${now}-${i}-${rand4()}`);
}
}
// Compute bounding box of non-arrow shapes to center around (500, 300)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const s of body.shapes) {
if (s.type === "folk-arrow") continue;
const sx = (s.x as number) ?? 0;
const sy = (s.y as number) ?? 0;
const sw = (s.width as number) ?? 0;
const sh = (s.height as number) ?? 0;
if (sx < minX) minX = sx;
if (sy < minY) minY = sy;
if (sx + sw > maxX) maxX = sx + sw;
if (sy + sh > maxY) maxY = sy + sh;
}
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const offsetX = isFinite(centerX) ? 500 - centerX : 0;
const offsetY = isFinite(centerY) ? 300 - centerY : 0;
// Remap shapes
const remapped: Record<string, unknown>[] = [];
for (const s of body.shapes) {
const clone = { ...s };
const oldId = clone.id as string;
const newId = idMap.get(oldId) || `copy-${now}-${remapped.length}-${rand4()}`;
clone.id = newId;
// Offset position
if (typeof clone.x === "number") clone.x = (clone.x as number) + offsetX;
if (typeof clone.y === "number") clone.y = (clone.y as number) + offsetY;
// Remap arrow endpoints (only if both are in the copied set)
if (clone.type === "folk-arrow") {
const srcMapped = idMap.get(clone.sourceId as string);
const tgtMapped = idMap.get(clone.targetId as string);
if (srcMapped && tgtMapped) {
clone.sourceId = srcMapped;
clone.targetId = tgtMapped;
}
}
// Update folk-rapp spaceSlug to target
if (clone.type === "folk-rapp") {
clone.spaceSlug = targetSlug;
}
// Strip forgotten/deleted fields (clean copy)
delete clone.forgotten;
delete clone.forgottenBy;
delete clone.deleted;
remapped.push(clone);
}
addShapes(targetSlug, remapped);
return c.json({ ok: true, count: remapped.length }, 201);
});
export { spaces }; export { spaces };

View File

@ -535,6 +535,106 @@
background: #3b1c1c; background: #3b1c1c;
} }
/* Sub-menu for "Copy to Space" */
.submenu-container {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
min-width: 160px;
max-height: 300px;
overflow-y: auto;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
padding: 4px;
display: none;
z-index: 10001;
}
.submenu.open {
display: block;
}
.submenu button {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
text-align: left;
font-size: 13px;
color: #1e293b;
border-radius: 6px;
cursor: pointer;
}
.submenu button:hover {
background: #f1f5f9;
}
.submenu .submenu-loading {
padding: 12px;
text-align: center;
font-size: 12px;
color: #94a3b8;
}
.has-submenu::after {
content: "▸";
float: right;
opacity: 0.5;
}
body[data-theme="dark"] .submenu {
background: #1e293b;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
body[data-theme="dark"] .submenu button {
color: #e2e8f0;
}
body[data-theme="dark"] .submenu button:hover {
background: #334155;
}
/* Toast notification */
#copy-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 10px 20px;
border-radius: 8px;
font-size: 13px;
color: white;
z-index: 100000;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
#copy-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#copy-toast.success { background: #16a34a; }
#copy-toast.error { background: #dc2626; }
/* Touch-friendly context menu buttons */
@media (pointer: coarse) {
#shape-context-menu button,
.submenu button {
min-height: 44px;
touch-action: manipulation;
}
}
#canvas { #canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -1169,6 +1269,7 @@
</div> </div>
<div id="shape-context-menu"></div> <div id="shape-context-menu"></div>
<div id="copy-toast"></div>
<div id="status" class="disconnected"> <div id="status" class="disconnected">
<span class="indicator"></span> <span class="indicator"></span>
@ -2751,21 +2852,109 @@
// ── Shape context menu (right-click on shapes) ── // ── Shape context menu (right-click on shapes) ──
const shapeContextMenu = document.getElementById("shape-context-menu"); const shapeContextMenu = document.getElementById("shape-context-menu");
let contextShapeId = null; let contextTargetIds = []; // IDs to operate on (single or multi-select)
// Cached spaces for copy submenu (fetched once per session)
let cachedSpaces = null;
async function fetchUserSpaces() {
if (cachedSpaces) return cachedSpaces;
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
if (!sess?.accessToken) return [];
const res = await fetch('/api/spaces/', {
headers: { 'Authorization': 'Bearer ' + sess.accessToken }
});
if (!res.ok) return [];
const data = await res.json();
cachedSpaces = (data.spaces || []).filter(
s => (s.relationship === 'owner' || s.relationship === 'member') && s.slug !== communitySlug
);
return cachedSpaces;
} catch { return []; }
}
function showToast(message, type = 'success') {
const toast = document.getElementById('copy-toast');
toast.textContent = message;
toast.className = 'show ' + type;
setTimeout(() => { toast.className = ''; }, 2500);
}
async function copyShapesToSpace(shapeIds, targetSlug) {
const shapes = [];
const idSet = new Set(shapeIds);
// Collect requested shapes
for (const id of shapeIds) {
const s = sync.doc?.shapes?.[id];
if (s) shapes.push(JSON.parse(JSON.stringify(s)));
}
// Also find arrows where BOTH endpoints are in the set
if (sync.doc?.shapes) {
for (const [id, s] of Object.entries(sync.doc.shapes)) {
if (idSet.has(id)) continue;
if (s.type === 'folk-arrow' && s.sourceId && s.targetId
&& idSet.has(s.sourceId) && idSet.has(s.targetId)) {
shapes.push(JSON.parse(JSON.stringify(s)));
}
}
}
if (shapes.length === 0) { showToast('No shapes to copy', 'error'); return; }
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
const res = await fetch(`/api/spaces/${targetSlug}/copy-shapes`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + sess.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({ shapes })
});
const data = await res.json();
if (res.ok) {
showToast(`Copied ${data.count} shape${data.count > 1 ? 's' : ''} to ${targetSlug}`);
} else {
showToast(data.error || 'Copy failed', 'error');
}
} catch (err) {
showToast('Network error', 'error');
}
}
canvasContent.addEventListener("contextmenu", (e) => { canvasContent.addEventListener("contextmenu", (e) => {
const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, folk-calendar, folk-map, folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-social-post, folk-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad"); const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, folk-calendar, folk-map, folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-social-post, folk-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad");
if (!shapeEl || !shapeEl.id) return; if (!shapeEl || !shapeEl.id) return;
e.preventDefault(); e.preventDefault();
contextShapeId = shapeEl.id;
const state = sync.getShapeVisualState(contextShapeId); // Multi-select: if clicked shape is in selection, operate on all selected; else just this one
if (selectedShapeIds.has(shapeEl.id) && selectedShapeIds.size > 1) {
contextTargetIds = [...selectedShapeIds];
} else {
contextTargetIds = [shapeEl.id];
}
const state = sync.getShapeVisualState(shapeEl.id);
const did = getLocalDID(); const did = getLocalDID();
const alreadyForgotten = sync.hasUserForgotten(contextShapeId, did); const alreadyForgotten = sync.hasUserForgotten(shapeEl.id, did);
const isAuthenticated = did !== 'anonymous';
let html = ''; let html = '';
if (state === 'present') { if (state === 'present') {
html = `<button data-action="forget">Forget</button>`; html += `<button data-action="forget">Forget</button>`;
if (isAuthenticated) {
const label = contextTargetIds.length > 1
? `Copy ${contextTargetIds.length} shapes to...`
: 'Copy to Space';
html += `<div class="submenu-container">`;
html += `<button data-action="copy-to-space" class="has-submenu">${label}</button>`;
html += `<div class="submenu" id="copy-space-submenu"><div class="submenu-loading">Loading spaces...</div></div>`;
html += `</div>`;
}
} else if (state === 'forgotten') { } else if (state === 'forgotten') {
html += `<button data-action="remember">Remember</button>`; html += `<button data-action="remember">Remember</button>`;
if (!alreadyForgotten) { if (!alreadyForgotten) {
@ -2778,41 +2967,82 @@
shapeContextMenu.style.left = e.clientX + 'px'; shapeContextMenu.style.left = e.clientX + 'px';
shapeContextMenu.style.top = e.clientY + 'px'; shapeContextMenu.style.top = e.clientY + 'px';
shapeContextMenu.classList.add("open"); shapeContextMenu.classList.add("open");
// Asynchronously populate the copy submenu
if (isAuthenticated && state === 'present') {
fetchUserSpaces().then(spaces => {
const sub = document.getElementById('copy-space-submenu');
if (!sub) return;
if (spaces.length === 0) {
sub.innerHTML = '<div class="submenu-loading">No other spaces</div>';
} else {
sub.innerHTML = spaces.map(s =>
`<button data-action="copy-to" data-slug="${s.slug}">${s.name || s.slug}</button>`
).join('');
}
});
}
}); });
shapeContextMenu.addEventListener("click", (e) => { shapeContextMenu.addEventListener("click", (e) => {
const btn = e.target.closest("button"); const btn = e.target.closest("button");
if (!btn || !contextShapeId) return; if (!btn || contextTargetIds.length === 0) return;
const action = btn.dataset.action; const action = btn.dataset.action;
const did = getLocalDID(); const did = getLocalDID();
if (action === 'copy-to-space') {
// Toggle submenu open/close — don't close the context menu
const sub = btn.closest('.submenu-container')?.querySelector('.submenu');
if (sub) sub.classList.toggle('open');
return;
}
if (action === 'copy-to') {
const slug = btn.dataset.slug;
if (slug) copyShapesToSpace(contextTargetIds, slug);
shapeContextMenu.classList.remove("open");
contextTargetIds = [];
return;
}
// Original actions operate on first target (single shape semantics)
const targetId = contextTargetIds[0];
if (action === 'forget') { if (action === 'forget') {
sync.forgetShape(contextShapeId, did); for (const id of contextTargetIds) {
const el = document.getElementById(contextShapeId); sync.forgetShape(id, did);
if (el) el.forgotten = true; const el = document.getElementById(id);
if (el) el.forgotten = true;
}
} else if (action === 'forget-too') { } else if (action === 'forget-too') {
sync.forgetShape(contextShapeId, did); for (const id of contextTargetIds) {
sync.forgetShape(id, did);
}
} else if (action === 'remember') { } else if (action === 'remember') {
sync.rememberShape(contextShapeId); for (const id of contextTargetIds) {
sync.rememberShape(id);
}
} else if (action === 'delete') { } else if (action === 'delete') {
sync.hardDeleteShape(contextShapeId); for (const id of contextTargetIds) {
sync.hardDeleteShape(id);
}
} }
shapeContextMenu.classList.remove("open"); shapeContextMenu.classList.remove("open");
contextShapeId = null; contextTargetIds = [];
if (memoryPanel.classList.contains("open")) renderMemoryPanel(); if (memoryPanel.classList.contains("open")) renderMemoryPanel();
}); });
// Close context menu on click/touch elsewhere // Close context menu on click/touch elsewhere
document.addEventListener("click", () => { document.addEventListener("click", (e) => {
if (e.target.closest("#shape-context-menu")) return;
shapeContextMenu.classList.remove("open"); shapeContextMenu.classList.remove("open");
contextShapeId = null; contextTargetIds = [];
}); });
document.addEventListener("touchstart", (e) => { document.addEventListener("touchstart", (e) => {
if (!e.target.closest("#shape-context-menu")) { if (!e.target.closest("#shape-context-menu")) {
shapeContextMenu.classList.remove("open"); shapeContextMenu.classList.remove("open");
contextShapeId = null; contextTargetIds = [];
} }
}); });