From 35685f765aaea784cba47395d570efc66886384b Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 15 Aug 2026 09:34:53 +0200 Subject: [PATCH] Add document locator beam --- e2e/board.smoke.spec.ts | 11 +++++++++ src/App.tsx | 52 ++++++++++++++++++++++++++++++++++++++--- src/styles.css | 15 +++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 4f8f1a4..b334f29 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -47,6 +47,17 @@ 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() + await page.getByRole('button', { name: 'EVIDENCE', exact: true }).click() + const documentRow = page.locator('[data-document-row-id="22222222-2222-4222-8222-222222222222"]') + await documentRow.click() + await expect(documentRow).toHaveClass(/\bselected\b/) + await expect(page.locator('[data-temporal-id="widget:11111111-1111-4111-8111-111111111111"]')).toHaveClass(/\bdocument-located\b/) + await expect(page.locator('.document-locator-beam .document-locator-ray')).toBeVisible() + await documentRow.dblclick() + await expect(page.getByRole('button', { name: 'Close document', exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Close document', exact: true }).click() + await page.getByRole('button', { name: 'Close documents', 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' }) diff --git a/src/App.tsx b/src/App.tsx index 3860885..6484b1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -367,7 +367,7 @@ export function App() { {canAuthor && <> { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} />}
- {filteredDocuments.map((doc, index) => )} @@ -411,6 +411,7 @@ export function App() {
+ document.id === selected) ? selected : null} layoutKey={`${docsOpen}:${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${caseState.evidence.map(item => `${item.id}:${item.x}:${item.y}:${String(item.config?.open)}`).join('|')}:${caseState.relations.map(item => `${item.id}:${String(item.config?.x)}:${String(item.config?.y)}`).join('|')}`} /> `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} @@ -712,14 +713,14 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), position = relationPosition(state, relation), origin = { x: folder.x + folder.width / 2, y: folder.y + 78 }; return })} - {state.evidence.map((ev, i) => { const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ const containedDocuments = containedIds(state, ev.id).flatMap(id => { const document = state.documents.find(candidate => candidate.id === id); return document ? [document] : [] }); const locatedDocumentId = ev.type === 'folder' && !folderIsOpen(ev) && containedDocuments.some(document => document.id === selected) ? selected : undefined; const definition = exhibitWidget(ev.type); const Widget = definition.Component; return
{ e.stopPropagation(); if (linkFrom && e.button === 0) return; if (tool === 'hand' || e.button === 1) { e.preventDefault(); pointerDown(e) } else if (e.button === 0) pointerDown(e, { kind: 'widget', id: ev.id }) }} onPointerMove={e => { e.stopPropagation(); pointerMove(e) }} onPointerUp={e => { e.stopPropagation(); finishDrag(e) }} onPointerCancel={e => { e.stopPropagation(); finishDrag(e) }} onAuxClick={e => { if (e.button === 1) e.preventDefault() }} onClick={e => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (tool === 'move') onCardClick(ev.id) }}>
{definition.heading(ev, containedDocuments)}{String(i + 1).padStart(3, '0')}
})} - {containmentRelations.map(relation => { const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return
{ const folder = byId.get(relation.fromWidgetId), document = state.documents.find(candidate => candidate.id === relation.toWidgetId); if (!folder || !document) return null; const open = folderIsOpen(folder), located = open && selected === document.id, target = relationPosition(state, relation); const left = open ? target.x : folder.x + folder.width / 2 - 87, top = open ? target.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; 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: 'relation', id: relation.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 (linkFrom) onConnectionTarget(document.id); else if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
{definition.label.toUpperCase()}{String((relation.sortOrder || 0) + 1).padStart(2, '0')}
@@ -731,6 +732,51 @@ function Board({ state, selected, linkFrom, recentlyCreatedExhibitId, recentlyCr } +function DocumentLocatorBeam({ documentId, layoutKey }: { documentId: string | null; layoutKey: string }) { + const [beam, setBeam] = useState<{ path: string; x: number; y: number } | null>(null) + useLayoutEffect(() => { + if (!documentId) { setBeam(null); return } + let frame = 0 + let animateUntil = Date.now() + 500 + const matchingElement = (attribute: 'documentRowId' | 'documentLocatorTarget') => Array.from(document.querySelectorAll(attribute === 'documentRowId' ? '[data-document-row-id]' : '[data-document-locator-target]')).find(element => element.dataset[attribute] === documentId) + const measure = () => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(function measureFrame() { + const source = matchingElement('documentRowId') + const target = matchingElement('documentLocatorTarget') + const viewport = document.querySelector('.board-viewport') + if (!source || !target || !viewport) { setBeam(null); return } + const from = source.getBoundingClientRect(), to = target.getBoundingClientRect(), bounds = viewport.getBoundingClientRect() + const x1 = Math.min(from.right - 3, bounds.left - 3) + const y1 = from.top + from.height / 2 + const x2 = Math.max(bounds.left + 12, Math.min(bounds.right - 12, to.left + to.width / 2)) + const y2 = Math.max(bounds.top + 12, Math.min(bounds.bottom - 12, to.top + to.height / 2)) + const bend = Math.max(70, Math.abs(x2 - x1) * .32) + setBeam({ path: `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`, x: x2, y: y2 }) + if (Date.now() < animateUntil) frame = requestAnimationFrame(measureFrame) + }) + } + measure() + const observer = new ResizeObserver(measure) + const observed = [matchingElement('documentRowId'), matchingElement('documentLocatorTarget'), document.querySelector('.board-viewport')].filter((element): element is HTMLElement => Boolean(element)) + observed.forEach(element => observer.observe(element)) + const handleLayoutChange = () => { animateUntil = Date.now() + 500; measure() } + window.addEventListener('resize', handleLayoutChange) + document.querySelector('.doc-list')?.addEventListener('scroll', handleLayoutChange, { passive: true }) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + window.removeEventListener('resize', handleLayoutChange) + document.querySelector('.doc-list')?.removeEventListener('scroll', handleLayoutChange) + } + }, [documentId, layoutKey]) + if (!beam) return null + return +} + function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey: string }) { const [lines, setLines] = useState<{ id: string; x1: number; y1: number; x2: number; y2: number }[]>([]) const itemsKey = items.map(item => `${item.id}:${item.date}`).join('|') diff --git a/src/styles.css b/src/styles.css index ec8367e..2fa1804 100644 --- a/src/styles.css +++ b/src/styles.css @@ -36,6 +36,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .no-document-results { min-height: 150px; padding: 32px 20px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: #617d75; }.no-document-results b { color: #91a59f; font: 600 9px IBM Plex Mono; letter-spacing: .08em; }.no-document-results span { font: 8px/1.5 IBM Plex Mono; } .doc-row { width: 100%; min-height: 76px; border: 0; border-bottom: 1px solid #243d36; background: transparent; padding: 11px 13px; display: grid; grid-template-columns: 45px 1fr 18px; text-align: left; align-items: center; gap: 10px; cursor: pointer; } .doc-row:hover { background: #18342e; } +.doc-row.selected { position: relative; background: #304534; box-shadow: inset 4px 0 #f1cf55, inset 0 0 20px #d8b94722; } +.doc-row.selected::after { content: 'LOCATED'; position: absolute; top: 7px; right: 9px; color: #f0ce57; font: 600 6px IBM Plex Mono; letter-spacing: .12em; } +.doc-row.selected .doc-icon { border-color: #e7c750; box-shadow: 0 0 12px #efd44f66, 3px 3px #081a16; } .doc-row strong { display: block; color: #d8dfda; font-size: 12px; margin-bottom: 6px; } .doc-row span { display: block; font: 9px IBM Plex Mono; color: #708b83; } .doc-row > svg { color: #527068; } @@ -103,6 +106,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .evidence-card { position: absolute; min-height: 138px; color: #1b2925; background: #d8d8ca; border: 1px solid #edece0; padding: 13px 15px 12px; box-shadow: 7px 9px 0 #020b0980, 0 0 0 1px #45524d; cursor: move; user-select: none; } .evidence-card::after { content: ''; position: absolute; left: 9px; right: 9px; bottom: -6px; height: 5px; background: #80847b; clip-path: polygon(0 0, 5% 50%, 12% 0, 20% 70%, 28% 0, 40% 60%, 49% 0, 61% 70%, 73% 0, 83% 60%, 91% 0, 100% 50%, 100% 100%, 0 100%); } .evidence-card.selected { outline: 2px solid #e49a4a; outline-offset: 5px; } +.evidence-card.document-located, .source-file-widget.document-located { z-index: 6; outline: 3px solid #f2d254; outline-offset: 6px; filter: drop-shadow(0 0 10px #f3d75d99) brightness(1.08); animation: document-locator-target 1.35s ease-in-out infinite alternate; } +@keyframes document-locator-target { from { outline-color: #cda936; filter: drop-shadow(0 0 5px #f3d75d66) brightness(1.03); } to { outline-color: #fff19a; filter: drop-shadow(0 0 14px #ffe66cbb) brightness(1.12); } } .evidence-card.linking { outline: 2px dashed #e49a4a; outline-offset: 7px; } .evidence-card.thread-target:hover, .source-file-widget.thread-target:hover { outline: 2px dashed #b8443e; outline-offset: 6px; } .evidence-card.arriving { z-index: 6; animation: exhibit-arrival 1.15s cubic-bezier(.18,.85,.22,1) both; } @@ -196,6 +201,14 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .file-drop-overlay { position: absolute; z-index: 20; inset: 14px; border: 2px dashed #e19a4d; background: #0a211de8; display: grid; place-items: center; pointer-events: none; }.file-drop-overlay > div { width: 290px; height: 150px; border: 1px solid #5e786f; background: #102c25; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: 9px 10px #020b09; color: #d99a58; }.file-drop-overlay b { font: 600 12px IBM Plex Mono; letter-spacing: .08em; }.file-drop-overlay span { font: 9px IBM Plex Mono; color: #78928a; letter-spacing: .12em; } .timeline { background: #0d231e; border-top: 1px solid #3c544d; display: grid; grid-template-columns: 180px 1fr 165px; align-items: center; padding: 0 25px; z-index: 8; } .temporal-links { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; pointer-events: none; overflow: visible; }.temporal-links line { stroke: #8b9792; stroke-width: 1; opacity: .42; vector-effect: non-scaling-stroke; } +.document-locator-beam { position: fixed; z-index: 9; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; } +.document-locator-beam path { fill: none; vector-effect: non-scaling-stroke; } +.document-locator-halo { stroke: #ffe276; stroke-width: 12; opacity: .13; filter: url(#document-locator-glow); } +.document-locator-ray { stroke: #ffe066; stroke-width: 2; stroke-dasharray: 10 8; opacity: .9; filter: url(#document-locator-glow); animation: document-locator-flow .7s linear infinite; } +.document-locator-beam circle { fill: #ffe36d; stroke: #fff3b0; stroke-width: 2; filter: url(#document-locator-glow); } +.document-locator-beam .document-locator-pulse { fill: none; opacity: .65; animation: document-locator-pulse 1.1s ease-out infinite; } +@keyframes document-locator-flow { to { stroke-dashoffset: -18; } } +@keyframes document-locator-pulse { from { r: 7; opacity: .8; } to { r: 24; opacity: 0; } } .timeline-label { border-right: 1px solid #314a43; height: 67px; display: flex; flex-direction: column; justify-content: center; } .timeline-label b { font: 600 15px IBM Plex Mono; margin: 4px 0; }.timeline-label button { width: max-content; max-width: 170px; padding: 1px 0; overflow: hidden; color: #d29a61; background: transparent; border: 0; text-overflow: ellipsis; white-space: nowrap; text-align: left; cursor: pointer; font: 500 11px IBM Plex Mono; } .timeline-track { height: 75px; margin: 0 43px; position: relative; } @@ -271,4 +284,4 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .board-actions > span { flex: 0 0 1px; width: 100%; height: 1px; margin: 3px 0; } .board-actions > b { padding: 2px 0; text-align: center; } } -@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } } +@media (prefers-reduced-motion: reduce) { .evidence-card.arriving, .brief-concepts section.just-resolved, .connections g.tightening path, .document-located, .document-locator-ray, .document-locator-pulse { animation: none; }.board, .documents-panel, .luggage-tag, .thread-tag-content { transition: none; } }