diff --git a/server/spaces.ts b/server/spaces.ts index ae7e47e..007e22a 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -27,6 +27,7 @@ import { setMember, removeMember, DEFAULT_COMMUNITY_NEST_POLICY, + addShapes, } from "./community-store"; import type { SpaceVisibility, @@ -1125,4 +1126,102 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => { 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[] }>(); + 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(); + 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[] = []; + 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 }; diff --git a/website/canvas.html b/website/canvas.html index 620129d..b95c353 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -535,6 +535,106 @@ 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 { width: 100%; height: 100%; @@ -1169,6 +1269,7 @@
+
@@ -2751,21 +2852,109 @@ // ── Shape context menu (right-click on shapes) ── 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) => { 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; 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 alreadyForgotten = sync.hasUserForgotten(contextShapeId, did); + const alreadyForgotten = sync.hasUserForgotten(shapeEl.id, did); + const isAuthenticated = did !== 'anonymous'; let html = ''; if (state === 'present') { - html = ``; + html += ``; + if (isAuthenticated) { + const label = contextTargetIds.length > 1 + ? `Copy ${contextTargetIds.length} shapes to...` + : 'Copy to Space'; + html += ``; + } } else if (state === 'forgotten') { html += ``; if (!alreadyForgotten) { @@ -2778,41 +2967,82 @@ shapeContextMenu.style.left = e.clientX + 'px'; shapeContextMenu.style.top = e.clientY + 'px'; 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 = ''; + } else { + sub.innerHTML = spaces.map(s => + `` + ).join(''); + } + }); + } }); shapeContextMenu.addEventListener("click", (e) => { const btn = e.target.closest("button"); - if (!btn || !contextShapeId) return; + if (!btn || contextTargetIds.length === 0) return; const action = btn.dataset.action; 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') { - sync.forgetShape(contextShapeId, did); - const el = document.getElementById(contextShapeId); - if (el) el.forgotten = true; + for (const id of contextTargetIds) { + sync.forgetShape(id, did); + const el = document.getElementById(id); + if (el) el.forgotten = true; + } } else if (action === 'forget-too') { - sync.forgetShape(contextShapeId, did); + for (const id of contextTargetIds) { + sync.forgetShape(id, did); + } } else if (action === 'remember') { - sync.rememberShape(contextShapeId); + for (const id of contextTargetIds) { + sync.rememberShape(id); + } } else if (action === 'delete') { - sync.hardDeleteShape(contextShapeId); + for (const id of contextTargetIds) { + sync.hardDeleteShape(id); + } } shapeContextMenu.classList.remove("open"); - contextShapeId = null; + contextTargetIds = []; if (memoryPanel.classList.contains("open")) renderMemoryPanel(); }); // 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"); - contextShapeId = null; + contextTargetIds = []; }); document.addEventListener("touchstart", (e) => { if (!e.target.closest("#shape-context-menu")) { shapeContextMenu.classList.remove("open"); - contextShapeId = null; + contextTargetIds = []; } });