From 703b9652c9ac2f57b4cfbe18b411269de2ec4c59 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 14 Aug 2026 19:37:43 +0200 Subject: [PATCH] Add drag-to-trash exhibit disposal --- e2e/board.smoke.spec.ts | 19 +++++++++++++++++++ src/App.tsx | 36 ++++++++++++++++++++++++++++++++---- src/boardDomain.test.ts | 25 +++++++++++++++++++++++++ src/boardDomain.ts | 23 +++++++++++++++++++++++ src/styles.css | 4 ++++ 5 files changed, 103 insertions(+), 4 deletions(-) diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index cbbc2ef..9e8e338 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -43,6 +43,25 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, await expect(page.locator('.brief-panel')).not.toHaveClass(/\bminimized\b/) await page.getByRole('button', { name: 'BEGIN INVESTIGATION', exact: true }).click() + page.once('dialog', dialog => dialog.accept('Disposable working theory')) + await waitForSave(page, () => page.getByRole('button', { name: 'NEW NOTE', exact: true }).click()) + const note = page.locator('.evidence-card.note').filter({ hasText: 'Disposable working theory' }) + const trash = page.locator('.board-trash') + await expect(note).toBeVisible() + const noteBox = await note.boundingBox(), trashBox = await trash.boundingBox() + if (!noteBox || !trashBox) throw new Error('Note or exhibit trash is not visible') + const noteDragStart = { x: noteBox.x + noteBox.width / 2, y: noteBox.y + noteBox.height / 2 } + const discardSave = page.waitForResponse(candidate => candidate.request().method() === 'PUT' && candidate.url().includes('/api/levels/') && candidate.ok()) + await page.mouse.move(noteDragStart.x, noteDragStart.y) + await page.mouse.down() + await page.mouse.move(trashBox.x + trashBox.width / 2, trashBox.y + trashBox.height / 2, { steps: 8 }) + await expect(trash).toHaveClass(/\bactive\b/) + await page.mouse.up() + await discardSave + await expect(note).toHaveCount(0) + await page.reload() + await expect(page.locator('.evidence-card.note')).toHaveCount(0) + const folder = page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]') const file = page.locator('[data-temporal-id="file:contains:11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222"]') const board = page.locator('.board') diff --git a/src/App.tsx b/src/App.tsx index f8f7c19..4387bbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
{ 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) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
- 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} /> + 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 && item.type === 'party')} @@ -452,7 +463,7 @@ function EmptyArchive({ canEdit, onCreated }: { canEdit: boolean; onCreated: (le return
GU
GLITCH UNIVERSITY LEVEL ARCHIVE

No investigations found.

The database is ready, but no authored level exists yet.

{canEdit ? :

Add ?edit=1 and enable level editing on the server to begin authoring.

}
} -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; 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; 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()) @@ -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(null) const [draggingThreadTagId, setDraggingThreadTagId] = useState(null) + const [draggingWidget, setDraggingWidget] = useState(false) + const [trashActive, setTrashActive] = useState(false) + const trashRef = useRef(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() }}> +
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04
diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts index 1d69f87..a6e3428 100644 --- a/src/boardDomain.test.ts +++ b/src/boardDomain.test.ts @@ -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() + }) +}) diff --git a/src/boardDomain.ts b/src/boardDomain.ts index b68e938..e2bd471 100644 --- a/src/boardDomain.ts +++ b/src/boardDomain.ts @@ -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 diff --git a/src/styles.css b/src/styles.css index 34e6a4d..c2df286 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }