feat: drag relation tags along threads

This commit is contained in:
2026-08-14 18:26:20 +02:00
parent 5d59af1dd9
commit b39d2efa75
11 changed files with 164 additions and 29 deletions
+33 -6
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseState, Connection, Evidence, LevelBrief, OrganizationKind, PartyKind, SourceFileType, TimelineRange, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, relationPosition, threadCurve, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { documentWidget, exhibitWidget } from './exhibitRegistry'
const BOARD_W = 2400
@@ -197,7 +197,7 @@ export function App() {
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
return
}
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage' })
setThreadDraft({ id: uid('connection'), fromEvidenceId: linkFrom, toEvidenceId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
setLinkFrom(null)
if (caseState.evidence.some(item => item.id === targetId)) setSelected(targetId)
}
@@ -453,12 +453,13 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
}
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onEditFolder, onEditFile, onEditEvent, onEditParty }: { state: CaseState; selected: string | null; linkFrom: string | null; recentlyCreatedExhibitId: string | null; recentlyCreatedConnectionId: string | null; tool: 'move' | 'hand'; boardRef: React.RefObject<HTMLDivElement | null>; update: (fn: (s: CaseState) => CaseState) => void; onCardClick: (id: string) => void; onConnectionTarget: (id: string) => void; onEditConnection: (connection: Connection) => void; onOpenSource: (id: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const drag = useRef<{ kind: 'pan' | 'widget' | 'relation' | 'thread-tag'; id?: string; startX: number; startY: number; originX: number; originY: number; moved?: boolean } | null>(null)
const suppressClick = useRef(false)
const touchPoints = useRef(new Map<number, { x: number; y: number }>())
const pinchDistance = useRef<number | null>(null)
const [threadPointer, setThreadPointer] = useState<{ x: number; y: number } | null>(null)
const [expandedThreadTagId, setExpandedThreadTagId] = useState<string | null>(null)
const [draggingThreadTagId, setDraggingThreadTagId] = useState<string | null>(null)
const byId = useMemo(() => new Map(state.evidence.map(e => [e.id, e])), [state.evidence])
const containmentRelations = state.relations.filter(relation => relation.type === 'contains')
const pointForId = (id: string) => {
@@ -502,6 +503,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
drag.current = { kind: target?.kind || 'pan', id: target?.id, startX: event.clientX, startY: event.clientY, originX: widget?.x ?? position?.x ?? state.viewport.x, originY: widget?.y ?? position?.y ?? state.viewport.y }
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
}
const threadTagPointerDown = (event: React.PointerEvent, id: string) => {
event.stopPropagation()
if (event.button !== 0) return
event.preventDefault()
setDraggingThreadTagId(id)
drag.current = { kind: 'thread-tag', id, startX: event.clientX, startY: event.clientY, originX: 0, originY: 0 }
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
}
const trackThreadPointer = (event: React.PointerEvent) => {
if (linkFrom && boardRef.current) {
const bounds = boardRef.current.getBoundingClientRect()
@@ -530,7 +539,18 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
if (drag.current.kind === 'thread-tag' && boardRef.current) {
if (drag.current.moved) setExpandedThreadTagId(null)
const connection = state.connections.find(item => item.id === drag.current!.id)
const from = connection ? pointForId(connection.fromEvidenceId) : undefined
const to = connection ? pointForId(connection.toEvidenceId) : undefined
if (connection && from && to) {
const bounds = boardRef.current.getBoundingClientRect()
const pointer = { x: (event.clientX - bounds.left - state.viewport.x) / state.viewport.zoom, y: (event.clientY - bounds.top - state.viewport.y) / state.viewport.zoom }
const placement = projectThreadTag(from, to, connection.tightness ?? 65, pointer)
update(s => ({ ...s, connections: s.connections.map(item => item.id === connection.id ? { ...item, tagPosition: placement.positionPercent, tagOffset: placement.lateralOffset } : item) }))
}
} else if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
}
@@ -541,6 +561,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
}
if (drag.current) suppressClick.current = Boolean(drag.current.moved)
drag.current = null
setDraggingThreadTagId(null)
}
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
const previewOrigin = linkFrom ? pointForId(linkFrom) : undefined
@@ -558,7 +579,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; return <g className={recentlyCreatedConnectionId === connection.id ? 'tightening' : ''} key={connection.id}><path d={threadCurve(p1, p2, connection.tightness).path}/><circle cx={p1.x} cy={p1.y} r="4"/><circle cx={p2.x} cy={p2.y} r="4"/></g> })}
{previewOrigin && threadPointer && <g className="thread-preview"><path d={threadCurve(previewOrigin, threadPointer, 35).path}/><circle cx={previewOrigin.x} cy={previewOrigin.y} r="4"/><circle cx={threadPointer.x} cy={threadPointer.y} r="3"/></g>}
</svg>
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const midpoint = threadCurve(p1, p2, connection.tightness).midpoint; const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''}`} style={{ left: midpoint.x, top: midpoint.y }} title={connection.label ? compact ? 'Edit compact relation tag' : expanded ? 'Click again to edit this thread' : 'Rotate relation tag to read' : 'Edit thread tag and tightness'} onPointerDown={event => event.stopPropagation()} onClick={event => { event.stopPropagation(); if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
{state.connections.map(connection => { const p1 = pointForId(connection.fromEvidenceId), p2 = pointForId(connection.toEvidenceId); if (!p1 || !p2) return null; const placement = threadTagPlacement(p1, p2, connection.tightness, connection.tagPosition, connection.tagOffset); const compact = connection.tagStyle === 'compact'; const expanded = !compact && expandedThreadTagId === connection.id; const dragging = draggingThreadTagId === connection.id; return <button key={`tag:${connection.id}`} aria-expanded={connection.label && !compact ? expanded : undefined} aria-label={connection.label ? `Relation tag: ${connection.label}` : 'Edit untagged red thread'} className={`thread-tag ${connection.label ? `labelled ${compact ? 'compact' : 'luggage luggage-tag'}` : 'untagged'} ${expanded ? 'expanded' : ''} ${dragging ? 'dragging' : ''}`} style={{ left: placement.x, top: placement.y }} title={connection.label ? dragging ? `Position ${Math.round(placement.positionPercent)}%` : compact ? 'Drag to position · click to edit' : expanded ? 'Click again to edit this thread' : 'Drag along thread · click to rotate' : 'Edit thread tag and tightness'} onPointerDown={event => connection.label ? threadTagPointerDown(event, connection.id) : event.stopPropagation()} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!connection.label || compact || expanded) { setExpandedThreadTagId(null); onEditConnection(connection) } else setExpandedThreadTagId(connection.id) }}><i/>{dragging && <output className="thread-tag-position">{Math.round(placement.positionPercent)}%</output>}{connection.label && (compact ? <span className="thread-tag-compact-label">{connection.label}</span> : <span className="thread-tag-content"><small>RELATION TAG</small><b>{connection.label}</b>{expanded && <em>CLICK AGAIN TO EDIT THREAD</em>}</span>)}</button> })}
<svg className="event-support-lines" width={BOARD_W} height={BOARD_H}>
{state.evidence.filter(event => event.type === 'event').flatMap(event => (event.supportingEvidenceIds || []).flatMap(evidenceId => {
const evidence = byId.get(evidenceId)
@@ -679,13 +700,19 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
const [label, setLabel] = useState(connection.label || '')
const [tightness, setTightness] = useState(connection.tightness ?? 65)
const [tagStyle, setTagStyle] = useState<'luggage' | 'compact'>(connection.tagStyle === 'compact' ? 'compact' : 'luggage')
const save = (tag = label) => onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle })
const [tagPosition, setTagPosition] = useState(connection.tagPosition ?? 50)
const save = (tag = label) => {
const lateralLimit = threadTagLateralLimit(tightness)
onSave({ ...connection, label: tag.trim() || undefined, tightness, tagStyle, tagPosition,
tagOffset: Math.max(-lateralLimit, Math.min(lateralLimit, connection.tagOffset ?? 0)) })
}
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
<div className="folder-editor-actions">{!isNew && <button className="danger" type="button" onClick={onRemove}>REMOVE THREAD</button>}<span/>{isNew && <button type="button" onClick={() => save('')}>SKIP TAG</button>}<button className="primary" type="submit">{isNew ? 'ADD TAG & TIGHTEN' : 'SAVE THREAD'}</button></div>
</div>