diff --git a/server/index.js b/server/index.js index 6d8ef26..babf271 100644 --- a/server/index.js +++ b/server/index.js @@ -83,6 +83,7 @@ function readDeskState() { cards: desk.cards || [], props: desk.props || [], strokes: desk.strokes || [], + yarnLinks: desk.yarnLinks || [], settings: desk.settings || {} } }; @@ -155,7 +156,7 @@ app.get('/api/desk', (_req, res) => { // Save desk state app.post('/api/desk', (req, res) => { - const { cards, props, strokes, settings, activeBoard, boards, originClientId } = req.body; + const { cards, props, strokes, yarnLinks, settings, activeBoard, boards, originClientId } = req.body; if (!Array.isArray(cards)) { return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' }); @@ -167,6 +168,7 @@ app.post('/api/desk', (req, res) => { cards, props: Array.isArray(props) ? props : [], strokes: Array.isArray(strokes) ? strokes : [], + yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : (updatedBoards[currentBoard]?.yarnLinks || []), settings: settings || {} }; @@ -174,6 +176,7 @@ app.post('/api/desk', (req, res) => { cards, props: Array.isArray(props) ? props : [], strokes: Array.isArray(strokes) ? strokes : [], + yarnLinks: Array.isArray(yarnLinks) ? yarnLinks : [], settings: settings || {}, activeBoard: currentBoard, boards: updatedBoards, diff --git a/src/App.tsx b/src/App.tsx index 7503ffa..0333444 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus, DeskTexture, BoardsMap } from './types'; +import { CardItem, DeskProp, DeskSettings, InkStroke, SyncStatus, DeskTexture, BoardsMap, YarnLink } from './types'; import { generateProceduralCards, SAMPLE_PRESETS } from './utils/proceduralCards'; import { soundEngine } from './utils/sound'; import { TopBar } from './components/TopBar'; @@ -36,6 +36,15 @@ export const App: React.FC = () => { const [cards, setCards] = useState([]); const [propsList, setPropsList] = useState(INITIAL_PROPS); const [strokes, setStrokes] = useState([]); + const [yarnLinks, setYarnLinks] = useState([]); + const [yarnDrag, setYarnDrag] = useState<{ + fromCardId: string; + fromX: number; + fromY: number; + currentX: number; + currentY: number; + hoveredCardId: string | null; + } | null>(null); const [maxZIndex, setMaxZIndex] = useState(50); const boardsRef = useRef({}); @@ -67,7 +76,8 @@ export const App: React.FC = () => { currentSettings: DeskSettings, immediate = false, targetBoard?: DeskTexture, - targetBoards?: BoardsMap + targetBoards?: BoardsMap, + currentYarnLinks?: YarnLink[] ) => { if (!isLoadedRef.current) return; @@ -76,12 +86,14 @@ export const App: React.FC = () => { } const currentBoard = targetBoard || currentSettings.deskTexture || 'oak'; + const activeYarnLinks = currentYarnLinks !== undefined ? currentYarnLinks : yarnLinks; const updatedBoards: BoardsMap = targetBoards || { ...boardsRef.current, [currentBoard]: { cards: currentCards, props: currentProps, strokes: currentStrokes, + yarnLinks: activeYarnLinks, settings: currentSettings } }; @@ -90,6 +102,7 @@ export const App: React.FC = () => { cards: currentCards, props: currentProps, strokes: currentStrokes, + yarnLinks: activeYarnLinks, settings: currentSettings }; } @@ -104,6 +117,7 @@ export const App: React.FC = () => { cards: currentCards, props: currentProps, strokes: currentStrokes, + yarnLinks: activeYarnLinks, activeBoard: currentBoard, boards: updatedBoards, settings: currentSettings, @@ -162,6 +176,22 @@ export const App: React.FC = () => { setCards(currentBoardData.cards || []); if (currentBoardData.props) setPropsList(currentBoardData.props); if (currentBoardData.strokes) setStrokes(currentBoardData.strokes); + if (Array.isArray(currentBoardData.yarnLinks)) { + setYarnLinks(currentBoardData.yarnLinks); + } else if (activeBoard === 'evidence-board' && currentBoardData.cards && currentBoardData.cards.length >= 2) { + setYarnLinks([ + { id: 'ev-link-1', fromId: 'ev-photo-1', toId: 'ev-tag-1' }, + { id: 'ev-link-2', fromId: 'ev-tag-1', toId: 'ev-memo' }, + { id: 'ev-link-3', fromId: 'ev-memo', toId: 'ev-photo-2' }, + { id: 'ev-link-4', fromId: 'ev-photo-1', toId: 'ev-ticket' }, + { id: 'ev-link-5', fromId: 'ev-ticket', toId: 'ev-check' }, + { id: 'ev-link-6', fromId: 'ev-check', toId: 'ev-receipt' }, + { id: 'ev-link-7', fromId: 'ev-receipt', toId: 'ev-graph' }, + { id: 'ev-link-8', fromId: 'ev-memo', toId: 'ev-graph' } + ]); + } else { + setYarnLinks([]); + } const boardSettings = currentBoardData.settings || desk.settings || {}; const isMobile = window.innerWidth < 768; @@ -202,14 +232,14 @@ export const App: React.FC = () => { ); setCards(initialCards); const initialBoards: BoardsMap = { - oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [], settings } + oak: { cards: initialCards, props: INITIAL_PROPS, strokes: [], yarnLinks: [], settings } }; boardsRef.current = initialBoards; setMaxZIndex(initialCards.length + 20); setSyncStatus('synced'); isLoadedRef.current = true; - saveToServer(initialCards, INITIAL_PROPS, [], settings, true, 'oak', initialBoards); + saveToServer(initialCards, INITIAL_PROPS, [], settings, true, 'oak', initialBoards, []); } }) .catch((err) => { @@ -244,6 +274,11 @@ export const App: React.FC = () => { } if (Array.isArray(payload.desk.props)) setPropsList(payload.desk.props); if (Array.isArray(payload.desk.strokes)) setStrokes(payload.desk.strokes); + if (Array.isArray(payload.desk.yarnLinks)) { + setYarnLinks(payload.desk.yarnLinks); + } else if (payload.desk.boards && payload.desk.boards[activeBoard]?.yarnLinks) { + setYarnLinks(payload.desk.boards[activeBoard].yarnLinks || []); + } const boardSettings = (payload.desk.boards && payload.desk.boards[activeBoard]?.settings) || payload.desk.settings; if (boardSettings) { setSettings((prev) => ({ @@ -301,6 +336,7 @@ export const App: React.FC = () => { cards, props: propsList, strokes, + yarnLinks, settings: prev } }; @@ -316,6 +352,7 @@ export const App: React.FC = () => { cards: seed, props: INITIAL_PROPS, strokes: [], + yarnLinks: [], settings: { deskTexture: 'oak', zoom: w < 768 ? 0.65 : 1.0, lightAngle: 225, grain: 0.7 } }; } else { @@ -347,10 +384,14 @@ export const App: React.FC = () => { soundEnabled: prev.soundEnabled }; + const nextYarnLinks = Array.isArray(nextBoardData.yarnLinks) ? nextBoardData.yarnLinks : []; + setYarnLinks(nextYarnLinks); + updatedBoards[nextDesk] = { cards: nextBoardData.cards || [], props: nextBoardData.props || INITIAL_PROPS, strokes: nextBoardData.strokes || [], + yarnLinks: nextYarnLinks, settings: nextSettings }; @@ -366,7 +407,8 @@ export const App: React.FC = () => { nextSettings, true, nextDesk, - updatedBoards + updatedBoards, + nextYarnLinks ); soundEngine.rustle(nextSettings.soundEnabled); @@ -424,16 +466,17 @@ export const App: React.FC = () => { cards: nextCards, props: propsList, strokes, + yarnLinks, settings: updated } }; boardsRef.current = updatedBoards; - saveToServer(nextCards, propsList, strokes, updated, false, currentDesk, updatedBoards); + saveToServer(nextCards, propsList, strokes, updated, false, currentDesk, updatedBoards, yarnLinks); return updated; }); }, - [cards, propsList, strokes, saveToServer] + [cards, propsList, strokes, yarnLinks, saveToServer] ); // Re-shuffle cards (stratified positioning so cards never clump or run over each other) @@ -657,6 +700,35 @@ export const App: React.FC = () => { // Global Pointer Move const handleGlobalPointerMove = (e: React.PointerEvent) => { + // 1. Yarn Link Dragging + if (yarnDrag) { + const zoom = settings.zoom || 1.0; + const deskX = e.clientX / zoom; + const deskY = e.clientY / zoom; + + // Find any card under pointer (excluding origin) + const hovered = visibleCards.find( + (c) => + c.id !== yarnDrag.fromCardId && + deskX >= c.x - 15 && + deskX <= c.x + c.width + 15 && + deskY >= c.y - 15 && + deskY <= c.y + c.height + 15 + ); + + setYarnDrag((prev) => + prev + ? { + ...prev, + currentX: deskX, + currentY: deskY, + hoveredCardId: hovered ? hovered.id : null + } + : null + ); + return; + } + if (!draggingIdRef.current) return; const zoom = settings.zoom || 1.0; @@ -684,6 +756,30 @@ export const App: React.FC = () => { // Global Pointer Up (Release with Momentum / Throw) const handleGlobalPointerUp = () => { + // 1. Handle Yarn Drag Release + if (yarnDrag) { + if (yarnDrag.hoveredCardId && yarnDrag.hoveredCardId !== yarnDrag.fromCardId) { + const fromId = yarnDrag.fromCardId; + const toId = yarnDrag.hoveredCardId; + const exists = yarnLinks.some( + (l) => (l.fromId === fromId && l.toId === toId) || (l.fromId === toId && l.toId === fromId) + ); + if (!exists) { + const newLink: YarnLink = { + id: `yarn-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, + fromId, + toId + }; + const nextLinks = [...yarnLinks, newLink]; + setYarnLinks(nextLinks); + saveToServer(cards, propsList, strokes, settings, false, settings.deskTexture, undefined, nextLinks); + soundEngine.drop(settings.soundEnabled); + } + } + setYarnDrag(null); + return; + } + if (!draggingIdRef.current) return; const id = draggingIdRef.current; @@ -733,6 +829,63 @@ export const App: React.FC = () => { } }; + // Interactive Evidence Yarn handlers + const handleStartYarnDrag = (cardId: string, startX: number, startY: number) => { + if (settings.isLocked) return; + setYarnDrag({ + fromCardId: cardId, + fromX: startX, + fromY: startY, + currentX: startX, + currentY: startY, + hoveredCardId: null + }); + soundEngine.rustle(settings.soundEnabled, 0.6); + }; + + const handleDeleteYarnLink = (linkId: string) => { + const nextLinks = yarnLinks.filter((l) => l.id !== linkId); + setYarnLinks(nextLinks); + saveToServer(cards, propsList, strokes, settings, false, settings.deskTexture, undefined, nextLinks); + soundEngine.rustle(settings.soundEnabled, 0.8); + }; + + const handleAutoLinkYarn = () => { + if (visibleCards.length < 2) return; + const newLinks: YarnLink[] = []; + const n = Math.min(visibleCards.length, 16); + for (let i = 0; i < n; i++) { + newLinks.push({ + id: `yarn-ring-${i}-${Date.now()}`, + fromId: visibleCards[i].id, + toId: visibleCards[(i + 1) % n].id + }); + } + if (n >= 4) { + newLinks.push({ + id: `yarn-chord-0-${Date.now()}`, + fromId: visibleCards[0].id, + toId: visibleCards[Math.floor(n / 2)].id + }); + } + if (n >= 6) { + newLinks.push({ + id: `yarn-chord-1-${Date.now()}`, + fromId: visibleCards[1].id, + toId: visibleCards[Math.floor(n / 2) + 1].id + }); + } + setYarnLinks(newLinks); + saveToServer(cards, propsList, strokes, settings, true, settings.deskTexture, undefined, newLinks); + soundEngine.shuffle(settings.soundEnabled); + }; + + const handleClearYarn = () => { + setYarnLinks([]); + saveToServer(cards, propsList, strokes, settings, true, settings.deskTexture, undefined, []); + soundEngine.rustle(settings.soundEnabled); + }; + // Keyboard Shortcuts (S, G, M, P, N, Space) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1001,6 +1154,13 @@ export const App: React.FC = () => { {/* 4. Stationery Props Layer */} diff --git a/src/components/EvidenceStringsLayer.tsx b/src/components/EvidenceStringsLayer.tsx index 842d7e9..97d6d2a 100644 --- a/src/components/EvidenceStringsLayer.tsx +++ b/src/components/EvidenceStringsLayer.tsx @@ -1,156 +1,341 @@ -import React, { useMemo } from 'react'; -import { CardItem } from '../types'; +import React, { useState } from 'react'; +import { CardItem, YarnLink } from '../types'; +import { Scissors, Sparkles, Trash2 } from 'lucide-react'; interface EvidenceStringsLayerProps { cards: CardItem[]; enabled: boolean; + yarnLinks: YarnLink[]; + onDeleteLink?: (linkId: string) => void; + activeDrag?: { + fromCardId: string; + fromX: number; + fromY: number; + currentX: number; + currentY: number; + hoveredCardId: string | null; + } | null; + onStartLinkDrag?: (cardId: string, startX: number, startY: number) => void; + onAutoLink?: () => void; + onClearLinks?: () => void; + maxZIndex?: number; } -export const EvidenceStringsLayer: React.FC = ({ cards, enabled }) => { - if (!enabled || cards.length < 2) return null; +export const EvidenceStringsLayer: React.FC = ({ + cards, + enabled, + yarnLinks, + onDeleteLink, + activeDrag, + onStartLinkDrag, + onAutoLink, + onClearLinks, + maxZIndex = 30 +}) => { + const [hoveredLinkId, setHoveredLinkId] = useState(null); - // Build stable connection pairs so threads don't flicker randomly - const connections = useMemo(() => { - const pairs: [number, number][] = []; - const n = Math.min(cards.length, 18); + if (!enabled) return null; - // 1. Perimeter ring connections - for (let i = 0; i < n; i++) { - pairs.push([i, (i + 1) % n]); - } - - // 2. Cross-board conspiracy chords - if (n >= 4) { - pairs.push([0, Math.floor(n / 2)]); - } - if (n >= 6) { - pairs.push([1, Math.floor(n / 2) + 1]); - pairs.push([Math.floor(n / 3), (Math.floor(n / 3) + Math.floor(n / 2)) % n]); - } - if (n >= 8) { - pairs.push([2, n - 2]); - } - - return pairs; - }, [cards.length]); + // Build a lookup map of cards for O(1) coordinate resolution + const cardMap = new Map(); + for (const card of cards) { + cardMap.set(card.id, card); + } return ( - - - {/* Subtle red yarn texture pattern filter */} - - - - - - - - {/* Render Yarn Threads */} - {connections.map(([idxA, idxB], i) => { - const cardA = cards[idxA]; - const cardB = cards[idxB]; - if (!cardA || !cardB) return null; - - const ax = cardA.x + cardA.width / 2; - const ay = cardA.y + 12; - const bx = cardB.x + cardB.width / 2; - const by = cardB.y + 12; - - const dist = Math.hypot(bx - ax, by - ay); - // Realistic catenary droop / sag - const sag = Math.min(45, Math.max(8, dist * 0.065)); - const midX = (ax + bx) / 2; - const midY = (ay + by) / 2 + sag; - - const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`; - - return ( - - {/* Thread shadow */} - - {/* Primary crimson red yarn */} - - {/* Fiber highlight core */} - + + + {/* Subtle red yarn texture pattern filter */} + + + + + + + {/* Radial glow for connection targets */} + + + + + + + + {/* 1. Render Existing Yarn Connections */} + {yarnLinks.map((link) => { + const cardA = cardMap.get(link.fromId); + const cardB = cardMap.get(link.toId); + if (!cardA || !cardB) return null; + + const ax = cardA.x + cardA.width / 2; + const ay = cardA.y + 12; + const bx = cardB.x + cardB.width / 2; + const by = cardB.y + 12; + + const dist = Math.hypot(bx - ax, by - ay); + // Realistic catenary droop / sag + const sag = Math.min(55, Math.max(10, dist * 0.075)); + const midX = (ax + bx) / 2; + const midY = (ay + by) / 2 + sag; + + const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`; + const isHovered = hoveredLinkId === link.id; + + return ( + setHoveredLinkId(link.id)} + onMouseLeave={() => setHoveredLinkId(null)} + > + {/* Invisible wide hit area for hovering & cutting */} + { + e.stopPropagation(); + onDeleteLink?.(link.id); + }} + > + Click or tap scissors to cut yarn connection + + + {/* Thread Shadow */} + + + {/* Primary crimson red yarn cord */} + + + {/* Fiber Highlight Core */} + + + {/* Interactive Cut Badge on hover */} + {isHovered && ( + { + e.stopPropagation(); + onDeleteLink?.(link.id); + }} + > + + + ✂ + + Cut yarn connection + + )} + + ); + })} + + {/* 2. Render Active Yarn Drag (Live string following pointer) */} + {activeDrag && ( + + {(() => { + const ax = activeDrag.fromX; + const ay = activeDrag.fromY; + const targetCard = activeDrag.hoveredCardId ? cardMap.get(activeDrag.hoveredCardId) : null; + const bx = targetCard ? targetCard.x + targetCard.width / 2 : activeDrag.currentX; + const by = targetCard ? targetCard.y + 12 : activeDrag.currentY; + + const dist = Math.hypot(bx - ax, by - ay); + const sag = Math.min(45, Math.max(6, dist * 0.06)); + const midX = (ax + bx) / 2; + const midY = (ay + by) / 2 + sag; + const pathData = `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${midX.toFixed(1)} ${midY.toFixed(1)} ${bx.toFixed(1)} ${by.toFixed(1)}`; + + return ( + <> + {/* Shadow */} + + {/* Glowing live yarn cord */} + + + {/* Floating lead pushpin on cursor */} + + + ); + })()} - ); - })} + )} - {/* Render Pushpins at each connection point on cards */} - {cards.map((card) => { - const px = card.x + card.width / 2; - const py = card.y + 12; + {/* 3. Render Interactive Pushpins on Every Card */} + {cards.map((card) => { + const px = card.x + card.width / 2; + const py = card.y + 12; - return ( - - {/* Pin shadow */} - - {/* Metallic brass bevel ring */} - - {/* Red plastic pushpin cap */} - - {/* Shiny specular highlight */} - - - ); - })} - + const isOrigin = activeDrag?.fromCardId === card.id; + const isTarget = activeDrag?.hoveredCardId === card.id; + + return ( + { + e.stopPropagation(); + onStartLinkDrag?.(card.id, px, py); + }} + > + {/* Highlight Aura if ready to snap/connect */} + {isTarget && ( + + )} + + {/* Pin Base Shadow */} + + + {/* Metallic brass bezel ring */} + + + {/* Red plastic pushpin head */} + + + {/* Specular 3D highlight */} + + + {/* Hover tooltip */} + Drag red yarn to another note to connect + + ); + })} + + + {/* 4. Evidence Web Control Banner (Floating hint when strings are active) */} +
+ + + Evidence Strings ({yarnLinks.length}) + + + | + + + Drag from any pushpin to link notes · Click thread to cut + + + {yarnLinks.length === 0 && onAutoLink && ( + + )} + + {yarnLinks.length > 0 && onClearLinks && ( + + )} +
+ ); }; diff --git a/src/types/index.ts b/src/types/index.ts index c879126..b1054a0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,10 +110,18 @@ export interface DeskSettings { showEvidenceStrings: boolean; // evidence board red yarn connections } +export interface YarnLink { + id: string; + fromId: string; + toId: string; + color?: string; +} + export interface BoardState { cards: CardItem[]; props?: DeskProp[]; strokes?: InkStroke[]; + yarnLinks?: YarnLink[]; settings?: Partial; } diff --git a/src/utils/boardPresets.ts b/src/utils/boardPresets.ts index d7cacbe..7503f39 100644 --- a/src/utils/boardPresets.ts +++ b/src/utils/boardPresets.ts @@ -474,10 +474,21 @@ export function getDefaultBoardData(desk: DeskTexture, w = 1400, h = 900): Board isPinned: true, attachType: 'pin', fontFamily: 'font-mono' } ]; + const defaultYarnLinks = [ + { id: 'ev-link-1', fromId: 'ev-photo-1', toId: 'ev-tag-1' }, + { id: 'ev-link-2', fromId: 'ev-tag-1', toId: 'ev-memo' }, + { id: 'ev-link-3', fromId: 'ev-memo', toId: 'ev-photo-2' }, + { id: 'ev-link-4', fromId: 'ev-photo-1', toId: 'ev-ticket' }, + { id: 'ev-link-5', fromId: 'ev-ticket', toId: 'ev-check' }, + { id: 'ev-link-6', fromId: 'ev-check', toId: 'ev-receipt' }, + { id: 'ev-link-7', fromId: 'ev-receipt', toId: 'ev-graph' }, + { id: 'ev-link-8', fromId: 'ev-memo', toId: 'ev-graph' } + ]; return { cards, props: INITIAL_PROPS, strokes: [], + yarnLinks: defaultYarnLinks, settings: { deskTexture: 'evidence-board', lightAngle: 215, showEvidenceStrings: true } }; }