Keep new evidence visible on the board
This commit is contained in:
+18
-6
@@ -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 <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||||
onPointerDown={event => { 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' && <span className="clip-stamped-pin" aria-hidden="true"/>}
|
||||
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||
|
||||
Reference in New Issue
Block a user