Вики: скачанные картинки показываются в тексте страницы (#135)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
829d4cbb4b
commit
952398bd9a
@ -4,7 +4,7 @@ import { Link } from "react-router";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Notice, IconExternal, IconPaperclip } from "../../ui/index.ts";
|
||||
import { fmtStamp } from "../../ui/format.ts";
|
||||
import { useWikiPage } from "./api/queries.ts";
|
||||
import { useWikiPage, type WikiAttachment } from "./api/queries.ts";
|
||||
import { useWikiDownload } from "./download.ts";
|
||||
import { wikiUrlTransform } from "./urlTransform.ts";
|
||||
|
||||
@ -26,23 +26,27 @@ const decode = (s: string) => { try { return decodeURIComponent(s); } catch { re
|
||||
// страница вики: заголовок, дата синхронизации, «открыть в Eva» и Markdown (таблицы GFM, без сырого HTML)
|
||||
export function PageView({ project, code, hrefOf }: { project: string; code: string; hrefOf(code: string): string }) {
|
||||
const page = useWikiPage(project, code);
|
||||
const dl = useWikiDownload(project, code);
|
||||
const atts = page.data?.attachments;
|
||||
const dl = useWikiDownload(project, code, atts);
|
||||
const failed = useMemo(() => new Map((atts ?? []).filter((a) => a.state === "failed" && a.error).map((a) => [String(a.id), a.error!])), [atts]);
|
||||
const byId = useMemo(() => new Map((atts ?? []).map((a) => [String(a.id), a])), [atts]);
|
||||
|
||||
const components = useMemo<Components>(() => ({
|
||||
...HEADINGS,
|
||||
a({ href, children }: ComponentProps<"a">) {
|
||||
if (!href) return <>{children}</>;
|
||||
if (/^page:/i.test(href)) return <Link to={hrefOf(decode(href.slice(5)))}>{children}</Link>;
|
||||
if (/^attachment:/i.test(href)) return <AttachmentLink id={decode(href.slice(11))} dl={dl} failed={failed}>{children}</AttachmentLink>;
|
||||
if (/^attachment:/i.test(href)) {
|
||||
const id = decode(href.slice(11));
|
||||
return <AttachmentLink id={id} att={byId.get(id)} dl={dl} failed={failed}>{children}</AttachmentLink>;
|
||||
}
|
||||
if (/^https?:/i.test(href)) return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
|
||||
return <a href={href}>{children}</a>;
|
||||
},
|
||||
// картинки по внешним адресам не загружаются (трекинг, чужие ресурсы): виден только текст
|
||||
img: ({ alt }: ComponentProps<"img">) => <span className="wiki-img">[изображение{alt ? `: ${alt}` : ""}]</span>,
|
||||
table: ({ children }: ComponentProps<"table">) => <div className="wiki-table"><table>{children}</table></div>,
|
||||
}), [hrefOf, dl, failed]);
|
||||
}), [hrefOf, dl, failed, byId]);
|
||||
|
||||
if (page.isPending) return <p className="wiki-hint">Загрузка страницы…</p>;
|
||||
if (page.isError) return <Notice tone="error">{page.error.message}</Notice>;
|
||||
@ -66,15 +70,19 @@ export function PageView({ project, code, hrefOf }: { project: string; code: str
|
||||
}
|
||||
|
||||
// ссылка attachment:<id>: запрос на скачивание, ожидание, сохранение файла; ошибка — текстом рядом
|
||||
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика
|
||||
function AttachmentLink({ id, dl, failed, children }: {
|
||||
id: string; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; children?: ReactNode;
|
||||
// failed — ошибки вложений в состоянии failed из ответа страницы: показываются сразу, до клика.
|
||||
// Картинка (att.image, #135): нажатие скачивает и показывает её в тексте; полученная — картинкой, под ней ссылка сохранения
|
||||
function AttachmentLink({ id, att, dl, failed, children }: {
|
||||
id: string; att?: WikiAttachment; dl: ReturnType<typeof useWikiDownload>; failed: Map<string, string>; children?: ReactNode;
|
||||
}) {
|
||||
const st = dl.status(id);
|
||||
const busy = st.busy, error = st.error ?? (busy ? null : failed.get(id) ?? null);
|
||||
const src = att?.image ? dl.image(id) : null;
|
||||
const mode = att?.image && !src ? "show" : "save";
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id)}>
|
||||
{src && <span className="wiki-att-img"><img src={src} alt={att?.name ?? ""} /></span>}
|
||||
<button type="button" className="wiki-att" disabled={busy} onClick={() => dl.start(id, mode)}>
|
||||
<IconPaperclip size={16} />{children}{busy && <span className="wiki-att__busy"> Загружается…</span>}
|
||||
</button>
|
||||
{error && <span className="wiki-att__error" role="alert"> {error}</span>}
|
||||
|
||||
@ -162,6 +162,28 @@ describe("страница «Вики»", () => {
|
||||
expect(await screen.findByText(/Eva: 404/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("#135: скачанная картинка видна в тексте, под ней ссылка сохранения", async () => {
|
||||
Object.assign(URL, { createObjectURL: () => "blob:img", revokeObjectURL: () => {} });
|
||||
const calls = hub((_m, url) => url.pathname === "/wiki/pages/DOC-3"
|
||||
? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "Схема", state: "ready", size: 3, error: null, image: true }] })
|
||||
: url.pathname === "/wiki/attachments/7/content" ? new Response("png") : undefined);
|
||||
open("/wiki?project=AWPCA&page=DOC-3");
|
||||
const pic = await screen.findByRole("img", { name: "Схема" });
|
||||
expect(pic).toHaveAttribute("src", "blob:img");
|
||||
expect(within(screen.getByRole("article")).getByRole("button", { name: /скачать/ })).toBeInTheDocument();
|
||||
expect(calls.some((c) => c.method === "POST")).toBe(false);
|
||||
Reflect.deleteProperty(URL, "createObjectURL"); Reflect.deleteProperty(URL, "revokeObjectURL");
|
||||
});
|
||||
|
||||
it("#135: не картинка в ready — ссылка без картинки", async () => {
|
||||
hub((_m, url) => url.pathname === "/wiki/pages/DOC-3"
|
||||
? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "model.xlsx", state: "ready", size: 3, error: null, image: false }] })
|
||||
: undefined);
|
||||
open("/wiki?project=AWPCA&page=DOC-3");
|
||||
expect(await within(await screen.findByRole("article")).findByRole("button", { name: /скачать/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("ссылка page: открывает страницу", async () => {
|
||||
hub();
|
||||
const { router } = open("/wiki?project=AWPCA&page=DOC-3");
|
||||
|
||||
@ -7,7 +7,7 @@ export interface WikiStatus {
|
||||
root: string; root_title: string | null; synced_at: string | null; pages: number; error: string | null; batch: WikiBatch | null;
|
||||
}
|
||||
export interface WikiNode { code: string; title: string | null; depth: number; parent_code: string | null; size: number | null }
|
||||
export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null }
|
||||
export interface WikiAttachment { id: number; name: string | null; state: string; size: number | null; error?: string | null; image?: boolean }
|
||||
export interface WikiPageData {
|
||||
code: string; title: string | null; path: string; url: string | null; synced_at: string | null; markdown: string; attachments: WikiAttachment[];
|
||||
}
|
||||
|
||||
@ -81,4 +81,73 @@ describe("useWikiDownload", () => {
|
||||
expect(result.current.status("7")).toEqual({ busy: false, error: ATT_TIMEOUT_TEXT });
|
||||
expect(gets).toBe(ATT_MAX_POLLS);
|
||||
});
|
||||
|
||||
// пакет D (#135): картинки в тексте страницы
|
||||
const img = (state: string, extra: object = {}) => ({ id: 7, name: "scheme.png", state, size: 3, error: null, image: true, ...extra });
|
||||
|
||||
it("ready-картинка: content запрошен при открытии, fetch не ставится, image(id) — objectURL", async () => {
|
||||
const f = mockFetch((_m, url) => (url.pathname.endsWith("/content") ? new Response("png") : json(404, {})));
|
||||
const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")]));
|
||||
await flush();
|
||||
expect(result.current.image("7")).toBe("blob:fake");
|
||||
expect(f.mock.calls.filter(([u]) => String(u) === "/wiki/attachments/7/content")).toHaveLength(1);
|
||||
expect(f.mock.calls.some(([, i]) => (i as RequestInit | undefined)?.method === "POST")).toBe(false);
|
||||
expect(clicked).toEqual([]);
|
||||
});
|
||||
|
||||
it("режим show: fetch → ready → картинка, файл не сохраняется", async () => {
|
||||
mockFetch((m, url) => {
|
||||
if (m === "POST") return json(202, st("fetching", { name: "scheme.png" }));
|
||||
if (url.pathname.endsWith("/content")) return new Response("png");
|
||||
return json(200, st("ready", { name: "scheme.png" }));
|
||||
});
|
||||
const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("none")]));
|
||||
await flush();
|
||||
expect(result.current.image("7")).toBeNull();
|
||||
act(() => result.current.start("7", "show"));
|
||||
await tick(ATT_POLL_MS);
|
||||
expect(result.current.image("7")).toBe("blob:fake");
|
||||
expect(result.current.status("7")).toEqual({ busy: false, error: null });
|
||||
expect(clicked).toEqual([]);
|
||||
});
|
||||
|
||||
it("content 409 у ready-картинки — картинки нет, ошибки нет", async () => {
|
||||
mockFetch((_m, url) => (url.pathname.endsWith("/content") ? json(409, { error: "not_ready" }) : json(404, {})));
|
||||
const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")]));
|
||||
await flush();
|
||||
expect(result.current.image("7")).toBeNull();
|
||||
expect(result.current.status("7")).toEqual({ busy: false, error: null });
|
||||
});
|
||||
|
||||
it("смена страницы: поздний content не кладёт картинку, objectURL отозваны", async () => {
|
||||
let release: (r: Response) => void = () => {};
|
||||
mockFetch((_m, url) => {
|
||||
if (url.pathname === "/wiki/attachments/7/content") return new Response("png");
|
||||
if (url.pathname === "/wiki/attachments/9/content") return new Promise<Response>((r) => { release = r; });
|
||||
return json(404, {});
|
||||
});
|
||||
const { result, rerender } = renderHook(({ scope, atts }) => useWikiDownload("AWPCA", scope, atts),
|
||||
{ initialProps: { scope: "DOC-1", atts: [img("ready"), img("ready", { id: 9 })] } });
|
||||
await flush();
|
||||
expect(result.current.image("7")).toBe("blob:fake");
|
||||
rerender({ scope: "DOC-2", atts: [] });
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
||||
release(new Response("late"));
|
||||
await flush();
|
||||
expect(result.current.image("9")).toBeNull();
|
||||
expect(result.current.image("7")).toBeNull();
|
||||
});
|
||||
|
||||
it("появление картинки не сбрасывает busy другого вложения", async () => {
|
||||
mockFetch((m, url) => {
|
||||
if (url.pathname === "/wiki/attachments/7/content") return new Response("png");
|
||||
if (m === "POST") return json(202, st("fetching", { id: 8 }));
|
||||
return json(200, st("fetching", { id: 8 }));
|
||||
});
|
||||
const { result } = renderHook(() => useWikiDownload("AWPCA", "DOC-1", [img("ready")]));
|
||||
act(() => result.current.start("8"));
|
||||
await flush();
|
||||
expect(result.current.image("7")).toBe("blob:fake");
|
||||
expect(result.current.status("8").busy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, ApiError } from "../../api/client.ts";
|
||||
import type { WikiAttachmentState } from "./api/queries.ts";
|
||||
import type { WikiAttachment, WikiAttachmentState } from "./api/queries.ts";
|
||||
|
||||
export const ATT_POLL_MS = 2000;
|
||||
export const ATT_MAX_POLLS = 60; // 60 × 2 с = 2 минуты: дольше агент, скорее всего, не ответит
|
||||
@ -20,29 +20,65 @@ function save(blob: Blob, filename: string) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
// хаб отдаёт содержимое как application/octet-stream: картинке — тип по расширению имени, без расширения браузер определит сам
|
||||
const MIME_BY_EXT: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
|
||||
function asImage(blob: Blob, name: string | null | undefined): Blob {
|
||||
const type = MIME_BY_EXT[(/\.([a-z0-9]+)$/i.exec(name ?? "")?.[1] ?? "").toLowerCase()];
|
||||
return type ? new Blob([blob], { type }) : blob;
|
||||
}
|
||||
|
||||
const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e);
|
||||
|
||||
// скачивание вложений страницы вики: запрос (POST …/fetch), опрос состояния, затем содержимое; состояние — на вложение.
|
||||
// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба
|
||||
export function useWikiDownload(project: string, scope: string) {
|
||||
// Опрос прекращается при размонтировании и смене страницы (scope): команда при этом остаётся в очереди хаба.
|
||||
// Картинки (image у вложения, #135): mode "show" показывает файл в тексте вместо сохранения; картинки в ready страница
|
||||
// получает сама при открытии — только содержимое, без нового скачивания агентом. objectURL живут до смены страницы
|
||||
export function useWikiDownload(project: string, scope: string, attachments?: WikiAttachment[]) {
|
||||
const [map, setMap] = useState<Record<string, DownloadStatus>>({});
|
||||
const [images, setImages] = useState<Record<string, string>>({});
|
||||
const mapRef = useRef(map);
|
||||
const imagesRef = useRef(images);
|
||||
const requested = useRef(new Set<string>());
|
||||
const gen = useRef(0);
|
||||
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
||||
const put = useCallback((id: string, s: DownloadStatus) => { mapRef.current = { ...mapRef.current, [id]: s }; setMap(mapRef.current); }, []);
|
||||
const putImage = useCallback((id: string, blob: Blob, name: string | null | undefined) => {
|
||||
const url = URL.createObjectURL(asImage(blob, name));
|
||||
const old = imagesRef.current[id];
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
imagesRef.current = { ...imagesRef.current, [id]: url };
|
||||
setImages(imagesRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current, counter = gen;
|
||||
const pending = timers.current, counter = gen, asked = requested.current;
|
||||
return () => {
|
||||
counter.current++;
|
||||
for (const t of pending) clearTimeout(t);
|
||||
pending.clear();
|
||||
asked.clear();
|
||||
for (const url of Object.values(imagesRef.current)) URL.revokeObjectURL(url);
|
||||
imagesRef.current = {};
|
||||
setImages({});
|
||||
mapRef.current = {};
|
||||
setMap({});
|
||||
};
|
||||
}, [project, scope]);
|
||||
|
||||
const start = useCallback((id: string) => {
|
||||
// картинки в ready — один запрос содержимого на вложение за жизнь страницы; неудача (409, сеть) оставляет ссылку
|
||||
useEffect(() => {
|
||||
const my = gen.current;
|
||||
for (const a of attachments ?? []) {
|
||||
const id = String(a.id);
|
||||
if (!a.image || a.state !== "ready" || requested.current.has(id)) continue;
|
||||
requested.current.add(id);
|
||||
api.blob(`/wiki/attachments/${encodeURIComponent(id)}/content`)
|
||||
.then((blob) => { if (gen.current === my) putImage(id, blob, a.name); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [attachments, putImage]);
|
||||
|
||||
const start = useCallback((id: string, mode: "save" | "show" = "save") => {
|
||||
if (mapRef.current[id]?.busy) return;
|
||||
const my = gen.current, alive = () => gen.current === my;
|
||||
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
|
||||
@ -53,7 +89,8 @@ export function useWikiDownload(project: string, scope: string) {
|
||||
if (st.state === "ready") {
|
||||
const blob = await api.blob(`${base}/content`);
|
||||
if (!alive()) return;
|
||||
save(blob, (st.name ?? "").trim() || `attachment-${id}`);
|
||||
if (mode === "show") putImage(id, blob, st.name);
|
||||
else save(blob, (st.name ?? "").trim() || `attachment-${id}`);
|
||||
put(id, IDLE);
|
||||
} else if (st.state === "fetching") {
|
||||
if (++polls > ATT_MAX_POLLS) return fail(ATT_TIMEOUT_TEXT);
|
||||
@ -69,9 +106,11 @@ export function useWikiDownload(project: string, scope: string) {
|
||||
};
|
||||
put(id, { busy: true, error: null });
|
||||
api.post<WikiAttachmentState>(`${base}/fetch`, { author: "web" }).then(handle).catch((e) => fail(errorText(e)));
|
||||
}, [put]);
|
||||
}, [put, putImage]);
|
||||
|
||||
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
||||
// объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует вложения
|
||||
return useMemo(() => ({ start, status }), [start, status]);
|
||||
const image = useCallback((id: string): string | null => images[id] ?? null, [images]);
|
||||
// объект стабилен между перерисовками родителя: от него зависит набор компонентов Markdown, смена ссылки перемонтирует
|
||||
// вложения (их состояние — здесь, в хуке, и перемонтирование его не сбрасывает)
|
||||
return useMemo(() => ({ start, status, image }), [start, status, image]);
|
||||
}
|
||||
|
||||
@ -56,6 +56,9 @@
|
||||
.wiki-att:hover:not(:disabled){text-decoration:underline}
|
||||
.wiki-att:disabled{cursor:default;color:var(--color-text-secondary)}
|
||||
.wiki-att:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
/* картинка-вложение в тексте страницы (#135): не шире колонки, под ней ссылка сохранения */
|
||||
.wiki-att-img{display:block;margin-block:var(--space-2) var(--space-1)}
|
||||
.wiki-att-img img{display:block;max-width:100%;height:auto;border-radius:var(--radius-md)}
|
||||
.wiki-att__busy{color:var(--color-text-secondary)}
|
||||
.wiki-att__error{color:var(--color-status-error)}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user