Add drag-to-trash exhibit disposal

This commit is contained in:
2026-08-14 19:37:43 +02:00
parent 18705fe55f
commit 703b9652c9
5 changed files with 103 additions and 4 deletions
+32 -4
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, projectThreadTag, relationPosition, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
import { clampBoardZoom, containedIds, dateValue, discardExhibit, 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
@@ -217,6 +217,17 @@ export function App() {
setStatus('RED THREAD REMOVED')
}
const removeExhibit = (id: string) => {
update(state => discardExhibit(state, id))
setSelected(current => current === id ? null : current)
setLinkFrom(current => current === id ? null : current)
setEditingFolderId(current => current === id ? null : current)
setEditingEventId(current => current === id ? null : current)
setEditingPartyId(current => current === id ? null : current)
setRecentlyCreatedExhibitId(current => current === id ? null : current)
setStatus('EXHIBIT DISCARDED · RELATIONS REMOVED')
}
const toggleThreadTool = () => {
if (linkFrom) { setLinkFrom(null); setStatus('RED THREAD CANCELLED') }
else if (selected) { setLinkFrom(selected); setStatus('RED THREAD READY · SELECT TARGET') }
@@ -331,7 +342,7 @@ export function App() {
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
{briefOpen && <BriefPanel
brief={caseState.brief}
parties={caseState.evidence.filter(item => item.type === 'party')}
@@ -452,7 +463,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le
return <main className="empty-archive"><div className="seal">GU</div><small>GLITCH UNIVERSITY LEVEL ARCHIVE</small><h1>No investigations found.</h1><p>The database is ready, but no authored level exists yet.</p>{canEdit ? <button disabled={creating} onClick={createLevel}><Plus size={17}/>{creating ? 'CREATING…' : 'CREATE FIRST LEVEL'}</button> : <p className="hint">Add <code>?edit=1</code> and enable level editing on the server to begin authoring.</p>}</main>
}
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onOpenSource, onUpdateDocumentCue, 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; onUpdateDocumentCue: (id: string, cue: string) => void; onEditFolder: (id: string) => void; onEditFile: (id: string) => void; onEditEvent: (id: string) => void; onEditParty: (id: string) => void }) {
function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCreatedConnectionId, tool, boardRef, update, onCardClick, onConnectionTarget, onEditConnection, onDiscardExhibit, onOpenSource, onUpdateDocumentCue, 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; onDiscardExhibit: (id: string) => void; onOpenSource: (id: string) => void; onUpdateDocumentCue: (id: string, cue: 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' | '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 }>())
@@ -460,6 +471,10 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
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 [draggingWidget, setDraggingWidget] = useState(false)
const [trashActive, setTrashActive] = useState(false)
const trashRef = useRef<HTMLDivElement>(null)
const trashTarget = useRef(false)
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) => {
@@ -501,6 +516,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
const relation = target?.kind === 'relation' ? state.relations.find(candidate => candidate.id === target.id) : undefined
const position = relation ? relationPosition(state, relation) : undefined
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 }
setDraggingWidget(target?.kind === 'widget')
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* Synthetic test events have no active native pointer. */ }
}
const threadTagPointerDown = (event: React.PointerEvent, id: string) => {
@@ -539,6 +555,12 @@ 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') {
const bounds = trashRef.current?.getBoundingClientRect()
const overTrash = Boolean(bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom)
trashTarget.current = overTrash
setTrashActive(overTrash)
}
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)
@@ -559,9 +581,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
touchPoints.current.delete(event.pointerId)
if (touchPoints.current.size < 2) pinchDistance.current = null
}
if (drag.current) suppressClick.current = Boolean(drag.current.moved)
const completedDrag = drag.current
if (completedDrag) suppressClick.current = Boolean(completedDrag.moved)
drag.current = null
setDraggingThreadTagId(null)
setDraggingWidget(false)
setTrashActive(false)
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
trashTarget.current = false
}
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
@@ -573,6 +600,7 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr
if (e.pointerType === 'touch' || tool === 'hand' || e.button === 1 || emptyBoardDrag) { e.preventDefault(); pointerDown(e) }
}}
onPointerMoveCapture={trackThreadPointer} onPointerMove={pointerMove} onPointerLeave={() => setThreadPointer(null)} onPointerUp={finishDrag} onPointerCancel={finishDrag} onAuxClick={e => { if (e.button === 1) e.preventDefault() }}>
<div ref={trashRef} className={`board-trash ${draggingWidget ? 'drag-ready' : ''} ${trashActive ? 'active' : ''}`} aria-hidden="true"><Trash2 size={23}/><span><b>DISCARD</b><small>DRAG EXHIBIT HERE</small></span></div>
<div className="board" style={{ width: BOARD_W, height: BOARD_H, transform: `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})` }}>
<div className="board-stamp">AUTHORIZED CITIZEN SCIENTIST WORKSTATION <span>GU-NET / 04</span></div>
<svg className="connections" width={BOARD_W} height={BOARD_H}>
+25
View File
@@ -5,6 +5,7 @@ import {
MIN_BOARD_ZOOM,
clampBoardZoom,
containedIds,
discardExhibit,
folderIsOpen,
moveBoardPoint,
nextOpenBoardPosition,
@@ -185,3 +186,27 @@ describe('folder domain behavior', () => {
expect(normalized.relations).toHaveLength(1)
})
})
describe('exhibit disposal', () => {
it('removes an exhibit and every graph reference while retaining source documents', () => {
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
const event = { ...folder, id: 'event-1', type: 'event' as const, supportingEvidenceIds: [note.id, 'doc-1'] }
const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] }
const discarded = discardExhibit({
...state,
documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }],
evidence: [folder, note, event, party],
relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }],
connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }],
brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
}, note.id)
expect(discarded.documents).toHaveLength(1)
expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id)
expect(discarded.relations).toEqual([])
expect(discarded.connections).toEqual([])
expect(discarded.evidence.find(exhibit => exhibit.id === event.id)?.supportingEvidenceIds).toEqual(['doc-1'])
expect(discarded.evidence.find(exhibit => exhibit.id === party.id)?.relatedEvidenceIds).toEqual([])
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
})
})
+23
View File
@@ -178,6 +178,29 @@ export function relationPosition(state: CaseState, relation: WidgetRelation) {
}
}
export function discardExhibit(state: CaseState, exhibitId: string): CaseState {
if (!state.evidence.some(exhibit => exhibit.id === exhibitId)) return state
return {
...state,
evidence: state.evidence
.filter(exhibit => exhibit.id !== exhibitId)
.map(exhibit => ({
...exhibit,
containedDocumentIds: exhibit.containedDocumentIds?.filter(id => id !== exhibitId),
supportingEvidenceIds: exhibit.supportingEvidenceIds?.filter(id => id !== exhibitId),
relatedEvidenceIds: exhibit.relatedEvidenceIds?.filter(id => id !== exhibitId),
})),
relations: state.relations.filter(relation => relation.fromWidgetId !== exhibitId && relation.toWidgetId !== exhibitId),
connections: state.connections.filter(connection => connection.fromEvidenceId !== exhibitId && connection.toEvidenceId !== exhibitId),
brief: {
...state.brief,
concepts: state.brief.concepts.map(concept => concept.resolvedPartyExhibitId === exhibitId
? { ...concept, resolvedPartyExhibitId: undefined }
: concept),
},
}
}
export function normalizeCase(state: CaseState): CaseState {
const relations = Array.isArray(state.relations)
? state.relations
+4
View File
@@ -55,6 +55,10 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.board-viewport.tool-hand, .board-viewport.tool-hand .evidence-card, .board-viewport.tool-hand .source-file-widget { cursor: grab; }
.board-viewport.tool-hand:active, .board-viewport.tool-hand .evidence-card:active, .board-viewport.tool-hand .source-file-widget:active { cursor: grabbing; }
.board-viewport.threading, .board-viewport.threading .evidence-card, .board-viewport.threading .source-file-widget { cursor: crosshair; }
.board-trash { position: absolute; z-index: 18; left: 18px; bottom: 18px; width: 136px; min-height: 64px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 1px dashed #6f5d50; background: #10231fdd; color: #866f61; box-shadow: 4px 5px 0 #02090788; opacity: .48; pointer-events: none; transition: opacity .16s ease, color .16s ease, border-color .16s ease, background .16s ease, transform .16s ease, box-shadow .16s ease; }
.board-trash span { display: grid; gap: 3px; }.board-trash b { font: 600 9px IBM Plex Mono; letter-spacing: .1em; }.board-trash small { font: 6px IBM Plex Mono; letter-spacing: .05em; white-space: nowrap; }
.board-trash.drag-ready { opacity: 1; color: #d69568; border-color: #b06e4f; background: #1c2b26ee; transform: translateY(-2px); }
.board-trash.active { color: #ffe2cf; border-color: #df5c48; background: #6d2823ed; box-shadow: 0 0 0 4px #c4473738, 6px 8px 0 #02090799; transform: scale(1.05); }
.board { transform-origin: 0 0; position: absolute; background: linear-gradient(110deg, #0c211d33, #122b251f); }
.board-stamp { position: absolute; left: 1480px; top: 240px; color: #6e807a2e; border: 2px solid #6e807a20; padding: 9px 13px; transform: rotate(-4deg); font: 600 11px IBM Plex Mono; }
.board-stamp span { display: block; text-align: center; font-size: 8px; margin-top: 4px; }