The Picker asked for `{ managementStatus: "READY", limit: 50 }` and
ignored `nextCursor`, so the 51st-oldest READY asset onward could not be
inserted at all. It sits inside the editing flow, where scrolling a long
list is the wrong interaction, so it gets search rather than a "더 보기"
control -- and it still loads a first page, because an empty panel until
you type is hostile to an author reaching for the asset they uploaded a
minute ago.
The trap this creates is the substance of the change. The editor screen's
Asset array feeds two consumers with opposite needs: the Picker's
*displayed* list, which a search must narrow, and Instant Preview's
*resolution catalog*, which a search must never narrow -- its gate
rejects any key no loaded asset backs. Handing search results straight to
the screen's `setAssets` would blank previously-inserted evidence figures
the moment the author typed a query.
They are kept apart by making the screen's callback additive by
construction rather than by convention: `mergeAssetCatalog` (domain,
beside `findResolvableAsset`) can only grow the set, and both writers --
observed pages and fresh uploads -- go through it. The prop is renamed
`onAssetsObserved` so the contract reads as "what the Picker saw", not
"what to show"; the replacing version was one `setAssets` reference away
and looked correct.
A key backed by neither the first page nor the current results is still
unresolvable. That is the known deferred limitation, not this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
import { useEffect, useId, useState, type FormEvent } from "react";
|
|
|
|
import type { Asset } from "../../../contracts/studio/contract.ts";
|
|
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
|
|
|
/** One screenful of candidates; searching, not scrolling, reaches the rest. */
|
|
const PAGE_SIZE = 50;
|
|
|
|
/**
|
|
* A directive attribute value is delimited by double quotes (see
|
|
* `parse-case-content.ts`'s `attributesOf`), so a value containing one would
|
|
* corrupt the directive -- and a raw newline would spill the directive across
|
|
* lines the block-directive parser does not expect inside an attribute. Both
|
|
* are stripped/folded rather than escaped: there is no escape syntax the
|
|
* parser accepts inside a directive attribute.
|
|
*/
|
|
function attributeValue(raw: string): string {
|
|
return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim();
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === "AbortError";
|
|
}
|
|
|
|
export function buildEvidenceDirective(
|
|
input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>,
|
|
): string {
|
|
const key = attributeValue(input.assetKey);
|
|
const alt = attributeValue(input.alt);
|
|
const caption = attributeValue(input.caption);
|
|
return `:::evidence key="${key}" alt="${alt}" caption="${caption}" zoom="${input.zoom}"\n:::`;
|
|
}
|
|
|
|
export function AssetPicker({
|
|
gateway,
|
|
onInsert,
|
|
onAssetsObserved,
|
|
}: Readonly<{
|
|
gateway: StudioAssetGateway;
|
|
onInsert: (directive: string) => void;
|
|
/**
|
|
* Every Asset this Picker has *seen* -- the first page, and each search's
|
|
* results -- reported as it arrives. Deliberately not "the Assets to show":
|
|
* the editor screen merges these into a resolution catalog that only grows
|
|
* (`mergeAssetCatalog`), because Instant Preview resolves `:::evidence`
|
|
* directives against that catalog and a narrowing search must never make an
|
|
* already-inserted figure stop rendering. Optional so Task 10 Step 1's
|
|
* original test (which renders `AssetPicker` on its own) keeps passing.
|
|
*/
|
|
onAssetsObserved?: (assets: readonly Asset[]) => void;
|
|
}>) {
|
|
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
|
const [status, setStatus] = useState<"LOADING" | "ERROR" | "READY">("LOADING");
|
|
// Alignment follow-up, item 2. `searchDraft` is what the author is typing,
|
|
// `q` the query actually submitted -- only `q` is an effect dependency. That
|
|
// is a stronger guarantee than debouncing (zero requests while typing rather
|
|
// than fewer, and no trailing request after the author stops), and it is the
|
|
// pair `document-list.tsx` already uses for the same job.
|
|
const [searchDraft, setSearchDraft] = useState("");
|
|
const [q, setQ] = useState("");
|
|
const searchId = useId();
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
// `RequestOptions.signal` is advisory: an adapter that ignores it still
|
|
// resolves, and an abandoned query's response would then replace the newer
|
|
// one's results. This flag flips synchronously when `q` changes, so an
|
|
// older search can never land last.
|
|
let active = true;
|
|
setStatus("LOADING");
|
|
gateway
|
|
.listAssets(
|
|
{ managementStatus: "READY", ...(q ? { q } : {}), limit: PAGE_SIZE },
|
|
{ signal: controller.signal },
|
|
)
|
|
.then((page) => {
|
|
if (!active) return;
|
|
setAssets(page.items);
|
|
setStatus("READY");
|
|
onAssetsObserved?.(page.items);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!active || isAbortError(error)) return;
|
|
setStatus("ERROR");
|
|
});
|
|
return () => {
|
|
active = false;
|
|
controller.abort();
|
|
};
|
|
}, [gateway, onAssetsObserved, q]);
|
|
|
|
// READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다.
|
|
// A query does not relax this: an asset under review is never insertable,
|
|
// however it was found.
|
|
const selectable = assets.filter((asset) => asset.managementStatus === "READY");
|
|
|
|
const submitSearch = (event: FormEvent) => {
|
|
event.preventDefault();
|
|
setQ(searchDraft.trim());
|
|
};
|
|
|
|
const listMessage = status === "LOADING"
|
|
? "Asset 목록을 불러오는 중입니다."
|
|
: selectable.length > 0
|
|
? `삽입할 수 있는 Asset ${selectable.length}개`
|
|
: q
|
|
? "검색 결과가 없습니다. 다른 검색어를 입력하세요."
|
|
: "삽입할 수 있는 Asset이 없습니다. 먼저 업로드하세요.";
|
|
|
|
return <div className="asset-picker">
|
|
{/* The search box stays mounted through the error and empty states: it is
|
|
the only way to reach an asset outside the first page, so removing it
|
|
with the list would strand the author on whatever went wrong. */}
|
|
<form className="asset-picker-search" role="search" onSubmit={submitSearch}>
|
|
<label htmlFor={searchId}>Asset 검색</label>
|
|
<div>
|
|
<input
|
|
id={searchId}
|
|
type="search"
|
|
value={searchDraft}
|
|
placeholder="Asset 키, 파일 이름"
|
|
onChange={(event) => setSearchDraft(event.target.value)}
|
|
/>
|
|
<button type="submit">검색</button>
|
|
</div>
|
|
</form>
|
|
{status === "ERROR"
|
|
? <p className="studio-error" role="alert">Asset 목록을 불러오지 못했습니다.</p>
|
|
: <p className="studio-asset-picker-empty" role="status">{listMessage}</p>}
|
|
{selectable.length > 0 ? <ul>
|
|
{selectable.map((asset) => <li key={asset.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onInsert(buildEvidenceDirective({
|
|
assetKey: asset.assetKey,
|
|
alt: asset.decorative ? "" : (asset.altText ?? ""),
|
|
caption: "",
|
|
zoom: asset.kind === "DIAGRAM",
|
|
}))}
|
|
>
|
|
{asset.assetKey}
|
|
</button>
|
|
</li>)}
|
|
</ul> : null}
|
|
</div>;
|
|
}
|