generated from coulomb/repo-seed
- Blob URL stability, scroll centre, strip-only visual guide - Focus-gated linking, unlink clears overlay, field badge tooltips - Capture layout (viewer centre), grey guide lines, Add field button - Workplans CE-WP-0006 (done) and CE-WP-0007 (T01-T09 done, T10-T12 todo) - Integration tests and viewer-url helpers
520 lines
14 KiB
TypeScript
520 lines
14 KiB
TypeScript
/**
|
|
* FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007).
|
|
*
|
|
* Layout (CE-WP-0007):
|
|
*
|
|
* ┌────────────┬─────────────────┬─────────────┐
|
|
* │ Collection │ ViewerShell │ FormPane │
|
|
* ├────────────┴─────────────────┴─────────────┤
|
|
* │ EvidenceStrip (bottom) │
|
|
* └────────────────────────────────────────────┘
|
|
*
|
|
* Linking: field must have focus; clicking evidence links directly.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import type { EvidenceItem } from "@shared/evidence";
|
|
import type { EvidenceLink } from "@shared/evidence-link";
|
|
import type { EvidenceItemId } from "@shared/ids";
|
|
|
|
import {
|
|
Overlay,
|
|
useActiveState,
|
|
useBinder,
|
|
useRegisterRect,
|
|
} from "@binder/index";
|
|
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
|
import {
|
|
CollectionList,
|
|
ViewerShell,
|
|
useActiveDocument,
|
|
useEngine,
|
|
useEngineEventTick,
|
|
useScrollToAnnotation,
|
|
} from "@work/index";
|
|
|
|
import { FormRenderer } from "@binder/FormRenderer";
|
|
|
|
import { DEMO_SCHEMA } from "./demo-schema";
|
|
import { HighlightRectBridge } from "./HighlightRectBridge";
|
|
|
|
export type EvidenceStripFilter = "all" | "attached";
|
|
|
|
const STRIP_FILTER_EVENT = "citation-evidence:strip-filter";
|
|
|
|
function publishStripFilter(mode: EvidenceStripFilter) {
|
|
if (typeof window === "undefined") return;
|
|
window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode }));
|
|
}
|
|
|
|
function quotePreview(text: string, max = 80): string {
|
|
const t = text.trim();
|
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
}
|
|
|
|
export function FormsApp() {
|
|
const [schema, setSchema] = useState<FormSchema>(() => ({
|
|
...DEMO_SCHEMA,
|
|
fields: [...DEMO_SCHEMA.fields],
|
|
}));
|
|
|
|
const fieldLabels = useMemo(
|
|
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
|
|
[schema],
|
|
);
|
|
|
|
const addField = useCallback(() => {
|
|
setSchema((prev) => {
|
|
const n = prev.fields.length + 1;
|
|
const field: FormFieldSchema = {
|
|
type: "text",
|
|
id: `field_${n}`,
|
|
label: `New field ${n}`,
|
|
};
|
|
return { ...prev, fields: [...prev.fields, field] };
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
|
<CollectionList />
|
|
<ViewerShell />
|
|
<FormPane schema={schema} onAddField={addField} />
|
|
</div>
|
|
<EvidenceStrip fieldLabels={fieldLabels} />
|
|
<ScrollBridge />
|
|
<HighlightRectBridge />
|
|
<Overlay />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScrollBridge() {
|
|
const { state } = useActiveState();
|
|
const { scrollTo } = useScrollToAnnotation();
|
|
useEffect(() => {
|
|
if (state.activeAnnotationId) {
|
|
scrollTo(state.activeAnnotationId);
|
|
}
|
|
}, [state.activeAnnotationId, scrollTo]);
|
|
return null;
|
|
}
|
|
|
|
function FormPane({
|
|
schema,
|
|
onAddField,
|
|
}: {
|
|
schema: FormSchema;
|
|
onAddField: () => void;
|
|
}) {
|
|
const { document } = useActiveDocument();
|
|
const { bindings } = useBinder();
|
|
const engine = useEngine();
|
|
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
|
const { state: activeState, setActiveEvidence } = useActiveState();
|
|
|
|
useEffect(() => {
|
|
return engine.bus.on("FormFieldActivated", () => {
|
|
publishStripFilter("attached");
|
|
});
|
|
}, [engine]);
|
|
|
|
useEffect(() => {
|
|
const target = activeState.activeTarget;
|
|
if (!target || activeState.activeEvidenceItemId) return;
|
|
const links = bindings.listEvidenceForTarget(target);
|
|
if (links.length === 0) return;
|
|
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
|
if (!item) return;
|
|
setActiveEvidence(item.id, item.annotationIds[0] ?? null);
|
|
}, [
|
|
activeState.activeTarget,
|
|
activeState.activeEvidenceItemId,
|
|
bindings,
|
|
engine,
|
|
linkTick,
|
|
unlinkTick,
|
|
setActiveEvidence,
|
|
]);
|
|
|
|
const linkCounts = useMemo<Record<string, number>>(() => {
|
|
const out: Record<string, number> = {};
|
|
for (const field of schema.fields) {
|
|
out[field.id] = bindings.listEvidenceForTarget({
|
|
targetType: "form-field",
|
|
targetId: field.id,
|
|
}).length;
|
|
}
|
|
void linkTick;
|
|
void unlinkTick;
|
|
return out;
|
|
}, [schema.fields, bindings, linkTick, unlinkTick]);
|
|
|
|
const linkHints = useMemo<Record<string, string>>(() => {
|
|
const out: Record<string, string> = {};
|
|
for (const field of schema.fields) {
|
|
const links = bindings.listEvidenceForTarget({
|
|
targetType: "form-field",
|
|
targetId: field.id,
|
|
});
|
|
if (links.length === 0) continue;
|
|
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
|
const ann = item?.annotationIds[0]
|
|
? engine.annotations.get(item.annotationIds[0])
|
|
: null;
|
|
const quote = ann?.quote ?? item?.commentary ?? "";
|
|
if (quote) out[field.id] = quotePreview(quote);
|
|
}
|
|
void linkTick;
|
|
void unlinkTick;
|
|
return out;
|
|
}, [schema.fields, bindings, engine, linkTick, unlinkTick]);
|
|
|
|
return (
|
|
<main
|
|
style={{
|
|
flex: "0 0 320px",
|
|
minWidth: 320,
|
|
borderLeft: "1px solid #ddd",
|
|
overflow: "auto",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{document ? (
|
|
<FormRenderer
|
|
schema={schema}
|
|
linkCounts={linkCounts}
|
|
linkHints={linkHints}
|
|
headerAction={
|
|
<button
|
|
type="button"
|
|
data-testid="add-field-button"
|
|
onClick={onAddField}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: "4px 10px",
|
|
border: "1px solid #888",
|
|
borderRadius: 4,
|
|
background: "white",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Add field
|
|
</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyHint />
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function EmptyHint() {
|
|
return (
|
|
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
|
Pick a document from the collection to start capturing evidence links.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function EvidenceStrip({
|
|
fieldLabels,
|
|
}: {
|
|
fieldLabels: ReadonlyMap<string, string>;
|
|
}) {
|
|
const engine = useEngine();
|
|
const { bindings } = useBinder();
|
|
const { document } = useActiveDocument();
|
|
const createTick = useEngineEventTick("EvidenceItemCreated");
|
|
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
|
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
|
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
|
const { state: activeState, setActiveEvidence, clearActiveEvidence } =
|
|
useActiveState();
|
|
|
|
const [userFilter, setUserFilter] = useState<EvidenceStripFilter>("all");
|
|
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
|
|
null,
|
|
);
|
|
|
|
const effectiveFilter = sessionFilter ?? userFilter;
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).detail);
|
|
};
|
|
window.addEventListener(STRIP_FILTER_EVENT, handler);
|
|
return () => window.removeEventListener(STRIP_FILTER_EVENT, handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!activeState.activeTarget) {
|
|
setSessionFilter(null);
|
|
}
|
|
}, [activeState.activeTarget]);
|
|
|
|
const allItems = useMemo<readonly EvidenceItem[]>(() => {
|
|
if (!document) return [];
|
|
void createTick;
|
|
void updateTick;
|
|
void linkTick;
|
|
void unlinkTick;
|
|
return engine.evidence.listByDocument(document.id);
|
|
}, [document, engine, createTick, updateTick, linkTick, unlinkTick]);
|
|
|
|
const items = useMemo(() => {
|
|
if (effectiveFilter !== "attached" || !activeState.activeTarget) {
|
|
return allItems;
|
|
}
|
|
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
|
|
const ids = new Set(links.map((l) => l.evidenceItemId));
|
|
const attached = allItems.filter((item) => ids.has(item.id));
|
|
return attached.length > 0 ? attached : allItems;
|
|
}, [
|
|
allItems,
|
|
effectiveFilter,
|
|
activeState.activeTarget,
|
|
bindings,
|
|
linkTick,
|
|
unlinkTick,
|
|
]);
|
|
|
|
const tryLink = useCallback(
|
|
(evidenceItemId: EvidenceItemId, fieldId: string): boolean => {
|
|
const existing = bindings
|
|
.listEvidenceForTarget({ targetType: "form-field", targetId: fieldId })
|
|
.some((l) => l.evidenceItemId === evidenceItemId);
|
|
if (existing) return false;
|
|
bindings.linkEvidenceToTarget({
|
|
evidenceItemId,
|
|
target: { targetType: "form-field", targetId: fieldId },
|
|
});
|
|
return true;
|
|
},
|
|
[bindings],
|
|
);
|
|
|
|
const handleCardClick = useCallback(
|
|
(item: EvidenceItem) => {
|
|
const annId = item.annotationIds[0] ?? null;
|
|
setActiveEvidence(item.id, annId);
|
|
|
|
const target = activeState.activeTarget;
|
|
if (target?.targetType === "form-field") {
|
|
tryLink(item.id, target.targetId);
|
|
}
|
|
},
|
|
[activeState.activeTarget, setActiveEvidence, tryLink],
|
|
);
|
|
|
|
const handleUnlink = useCallback(
|
|
(link: EvidenceLink) => {
|
|
bindings.unlinkEvidence(link.id);
|
|
if (
|
|
activeState.activeEvidenceItemId === link.evidenceItemId &&
|
|
activeState.activeTarget?.targetType === link.targetType &&
|
|
activeState.activeTarget?.targetId === link.targetId
|
|
) {
|
|
clearActiveEvidence();
|
|
}
|
|
},
|
|
[bindings, activeState, clearActiveEvidence],
|
|
);
|
|
|
|
if (!document) return null;
|
|
|
|
return (
|
|
<section
|
|
aria-label="Evidence list"
|
|
style={{
|
|
borderTop: "1px solid #ddd",
|
|
background: "#fafafa",
|
|
padding: 8,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 6,
|
|
flex: "0 0 auto",
|
|
minHeight: 100,
|
|
fontFamily: "system-ui, sans-serif",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
|
|
<span style={{ color: "#666" }}>Show:</span>
|
|
<FilterToggle
|
|
label="All"
|
|
active={effectiveFilter === "all"}
|
|
onClick={() => {
|
|
setUserFilter("all");
|
|
setSessionFilter(null);
|
|
}}
|
|
/>
|
|
<FilterToggle
|
|
label="Linked to field"
|
|
active={effectiveFilter === "attached"}
|
|
onClick={() => {
|
|
setUserFilter("attached");
|
|
setSessionFilter(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
|
|
{items.length === 0 && (
|
|
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
|
{effectiveFilter === "attached"
|
|
? "No evidence linked to the active field."
|
|
: "No evidence yet. Switch to Review mode to capture a passage."}
|
|
</p>
|
|
)}
|
|
{items.map((item) => (
|
|
<EvidenceStripCard
|
|
key={item.id}
|
|
item={item}
|
|
isActive={activeState.activeEvidenceItemId === item.id}
|
|
links={bindings.listTargetsForEvidence(item.id)}
|
|
fieldLabels={fieldLabels}
|
|
onClick={() => handleCardClick(item)}
|
|
onUnlink={handleUnlink}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function FilterToggle({
|
|
label,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: "2px 8px",
|
|
borderRadius: 4,
|
|
border: active ? "1px solid #0050b3" : "1px solid #ccc",
|
|
background: active ? "#e8f0ff" : "white",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function EvidenceStripCard({
|
|
item,
|
|
isActive,
|
|
links,
|
|
fieldLabels,
|
|
onClick,
|
|
onUnlink,
|
|
}: {
|
|
item: EvidenceItem;
|
|
isActive: boolean;
|
|
links: readonly EvidenceLink[];
|
|
fieldLabels: ReadonlyMap<string, string>;
|
|
onClick: () => void;
|
|
onUnlink: (link: EvidenceLink) => void;
|
|
}) {
|
|
const engine = useEngine();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useRegisterRect("evidence-card", item.id, ref);
|
|
|
|
const firstAnn = item.annotationIds[0]
|
|
? engine.annotations.get(item.annotationIds[0])
|
|
: null;
|
|
const quote = firstAnn?.quote ?? "(no quote)";
|
|
|
|
const formLinks = links.filter((l) => l.targetType === "form-field");
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
style={{
|
|
position: "relative",
|
|
minWidth: 220,
|
|
maxWidth: 280,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{formLinks.length > 0 && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 4,
|
|
right: 4,
|
|
display: "flex",
|
|
gap: 2,
|
|
zIndex: 1,
|
|
}}
|
|
>
|
|
{formLinks.map((link) => {
|
|
const label = fieldLabels.get(link.targetId) ?? link.targetId;
|
|
return (
|
|
<button
|
|
key={link.id}
|
|
type="button"
|
|
title={`Linked to: ${label}. Click to remove link.`}
|
|
aria-label={`Remove link to ${label}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onUnlink(link);
|
|
}}
|
|
style={{
|
|
fontSize: 10,
|
|
lineHeight: 1,
|
|
padding: "2px 4px",
|
|
border: "1px solid #88a",
|
|
borderRadius: 3,
|
|
background: "#eef",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
⧉
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-current={isActive ? "true" : undefined}
|
|
style={{
|
|
width: "100%",
|
|
textAlign: "left",
|
|
fontSize: 12,
|
|
padding: 8,
|
|
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
|
|
background: isActive ? "#e8f0ff" : "white",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontStyle: "italic",
|
|
marginBottom: 4,
|
|
paddingRight: formLinks.length ? 24 : 0,
|
|
}}
|
|
>
|
|
“{quote.slice(0, 100)}
|
|
{quote.length > 100 ? "…" : ""}”
|
|
</div>
|
|
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
|
</button>
|
|
</div>
|
|
);
|
|
} |