From 9e426e91ab80e9fe405f41971ed0b7d85afacb70 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sun, 23 Aug 2026 01:15:05 +0200 Subject: [PATCH] Keep new evidence visible on the board --- src/App.tsx | 24 +++++++++++---- src/boardDomain.test.ts | 27 +++++++++++++++++ src/boardDomain.ts | 65 +++++++++++++++++++++++++++++++++++++++++ src/styles.css | 3 +- 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2e9816f..507fb04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types' import { AdminPanel } from './admin' import { audio } from './audio' -import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' +import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain' import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry' import type { PlaythroughState } from './narrative' @@ -381,13 +381,23 @@ export function App() { const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => { if (!caseState) return const queue = Array.from(files) + const boardScreen = boardRef.current?.getBoundingClientRect() + const screen = { width: boardScreen?.width || window.innerWidth, height: boardScreen?.height || window.innerHeight } + const portraitMobile = screen.width <= 900 && screen.height > screen.width + const insets = { + top: Math.min(140, screen.height * .28), + right: portraitMobile ? 68 : 18, + bottom: portraitMobile ? 18 : 68, + left: 18, + } + const reserved = [...caseState.exhibits] setUploading(queue.length) setDraggingFiles(false) - for (const [queueIndex, file] of queue.entries()) { - const position = nextOpenBoardPosition(caseState.exhibits, { - x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24, - y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24, - }, { width: 174, height: 145 }) + for (const file of queue) { + // Reserve for the largest image presentation so choosing Clip, Image, or + // Mugshot in the following dialog cannot make it grow beyond the viewport. + const reservedSize = { width: 244, height: 294 } + const position = nextVisibleBoardPosition(reserved, caseState.viewport, screen, reservedSize, insets, { width: BOARD_W, height: BOARD_H }) const form = new FormData() form.append('file', file) form.append('x', String(position.x)) @@ -398,6 +408,7 @@ export function App() { if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) } const uploaded: UploadedCaseDocument = await response.json() const { analysis, ...document } = uploaded + reserved.push({ ...document, ...position, ...reservedSize }) update(s => { return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] } }) @@ -974,6 +985,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx {documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return
{ event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }} onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}> + {document.captureKind === 'clipping' &&