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 ARCHIVENo 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() }}>
+ DISCARDDRAG EXHIBIT HERE
AUTHORIZED CITIZEN SCIENTIST WORKSTATION GU-NET / 04