From c3de2cd1027cdba4b6cf112f2b6e0ffd552e5667 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 24 Sep 2026 21:19:23 +0800 Subject: [PATCH] feat: add AI-driven infographic notes --- apps/api/src/memo-list-service.ts | 2 + .../EdgeEver/Editor/TipTapContentSource.swift | 12 + .../Features/Workspace/MemoDetailView.swift | 14 +- .../TipTapContentSourceTests.swift | 12 + apps/mobile/src/lib/mobile-diagram.test.ts | 12 +- apps/mobile/src/lib/mobile-diagram.ts | 10 + .../src/screens/WorkspaceMemoDetail.tsx | 4 +- apps/web/package.json | 3 +- .../src/components/InfographicEditorPane.tsx | 385 ++++++++++++++++++ apps/web/src/components/MemoCard.tsx | 4 +- apps/web/src/components/NotebookPane.tsx | 6 + apps/web/src/components/WorkspaceApp.tsx | 37 +- .../src/components/WorkspaceMobileChrome.tsx | 6 +- .../src/lib/infographic-generation.test.mjs | 129 ++++++ apps/web/src/lib/infographic-generation.ts | 303 ++++++++++++++ apps/web/src/lib/local-mirror.ts | 2 + apps/web/src/lib/workspace-memo-cache.ts | 2 + bun.lock | 79 +++- packages/shared/src/i18n/en-US.ts | 16 + packages/shared/src/i18n/ja.ts | 16 + packages/shared/src/i18n/zh-CN.ts | 16 + packages/shared/src/index.ts | 1 + packages/shared/src/infographic.test.mjs | 26 ++ packages/shared/src/infographic.ts | 48 +++ packages/shared/src/types.ts | 3 +- 25 files changed, 1133 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/InfographicEditorPane.tsx create mode 100644 apps/web/src/lib/infographic-generation.test.mjs create mode 100644 apps/web/src/lib/infographic-generation.ts create mode 100644 packages/shared/src/infographic.test.mjs create mode 100644 packages/shared/src/infographic.ts diff --git a/apps/api/src/memo-list-service.ts b/apps/api/src/memo-list-service.ts index 46503541..bbfbd230 100644 --- a/apps/api/src/memo-list-service.ts +++ b/apps/api/src/memo-list-service.ts @@ -4,6 +4,7 @@ import { docToText, markdownToDoc, getDiagramSummary, + getInfographicSummary, getTableSummary, type MemoSummary, } from "@edgeever/shared"; @@ -71,6 +72,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({ createExcerpt(row.content_text ?? "") || createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))), ...getDiagramSummary(row.content_markdown), + ...getInfographicSummary(row.content_markdown), ...getTableSummary(row.content_markdown), tags: parseJsonArray(row.tags_json), isPinned: Boolean(row.is_pinned), diff --git a/apps/ios/EdgeEver/Editor/TipTapContentSource.swift b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift index 3fbd9d99..b3f3685c 100644 --- a/apps/ios/EdgeEver/Editor/TipTapContentSource.swift +++ b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift @@ -32,6 +32,10 @@ enum TipTapContentSource: Sendable { let stripped = stripStructuredTableMarker(markdown) return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)") } + if markdown.contains(""#, + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + /// True when TipTap JSON stores at least one image `width` (25–100 display size). static func jsonHasImageWidth(_ json: String) -> Bool { // Match `"width":50` / `"width": 50` near image nodes without full parse. diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift index 11fa9315..d3ca9fd1 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift @@ -899,12 +899,16 @@ struct MemoDetailView: View { } .buttonStyle(.plain) .disabled(memo.isDeleted || blocksRichTextEdit(memo)) - .accessibilityLabel(isStructuredTable(memo) + .accessibilityLabel(isInfographic(memo) + ? env.preferences.t("信息图标题", en: "Infographic title") + : isStructuredTable(memo) ? env.preferences.t("表格标题", en: "Database title") : isVisualDiagram(memo) ? env.preferences.t("图表标题", en: "Diagram title") : env.preferences.t("编辑笔记标题", en: "Edit note title")) - .accessibilityHint(isStructuredTable(memo) + .accessibilityHint(isInfographic(memo) + ? env.preferences.t("信息图请在 Web 或桌面端编辑", en: "Edit infographics on Web or desktop") + : isStructuredTable(memo) ? env.preferences.t("多维表格请在 Web 或桌面端编辑", en: "Edit databases on Web or desktop") : isVisualDiagram(memo) ? env.preferences.t("可视化图表请在 Web 或桌面端编辑", en: "Edit visual diagrams on Web or desktop") @@ -933,8 +937,12 @@ struct MemoDetailView: View { memo.contentMarkdown.contains("" + let decision = TipTapContentSource.resolve( + mode: .viewer, + documentJSON: #"{"type":"doc","content":[{"type":"paragraph"}]}"#, + markdown: markdown + ) + XCTAssertFalse(decision.useJSON) + XCTAssertFalse(decision.payload.contains("edgeever-infographic-v1")) + XCTAssertTrue(decision.payload.contains("infographic list-row-simple-horizontal-arrow")) + } + func testEditorMarkdownRemainsAuthoritativeForRichStructures() { let markdown = """ 1. first diff --git a/apps/mobile/src/lib/mobile-diagram.test.ts b/apps/mobile/src/lib/mobile-diagram.test.ts index f9370466..b530f734 100644 --- a/apps/mobile/src/lib/mobile-diagram.test.ts +++ b/apps/mobile/src/lib/mobile-diagram.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; -import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared"; -import { getMobileVisualDiagramKind, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram"; +import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared"; +import { getMobileVisualDiagramKind, hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram"; const hasMermaidCodeBlock = (doc: { content?: Array<{ type?: string; attrs?: { language?: string } }> }) => Boolean(doc.content?.some((node) => node.type === "codeBlock" && node.attrs?.language === "mermaid")); @@ -47,6 +47,14 @@ describe("mobile visual diagram viewer", () => { expect(JSON.stringify(resolveMobileMemoViewerContent(null, broken))).not.toContain("edgeever-table-v1"); }); + test("shows infographic syntax without exposing its marker to the native viewer", () => { + const markdown = serializeInfographicDocument({ schemaVersion: 1, syntax: "infographic list-row-simple-horizontal-arrow\ndata\n title Test" }); + expect(hasMobileInfographic(markdown)).toBe(true); + const viewerContent = resolveMobileMemoViewerContent(null, markdown); + expect(JSON.stringify(viewerContent)).toContain("infographic list-row-simple-horizontal-arrow"); + expect(JSON.stringify(viewerContent)).not.toContain("edgeever-infographic-v1"); + }); + test("fills the remaining phone viewport instead of shrinking the canvas to a postage stamp", () => { const source = readFileSync(new URL("../components/LocalTiptapEditor.tsx", import.meta.url), "utf8"); expect(source).toContain(".edgeever-editor-scroll:has(.edgeever-x6-document) { display: flex; flex-direction: column; overflow: hidden; }"); diff --git a/apps/mobile/src/lib/mobile-diagram.ts b/apps/mobile/src/lib/mobile-diagram.ts index 2f206ac6..4bb877a4 100644 --- a/apps/mobile/src/lib/mobile-diagram.ts +++ b/apps/mobile/src/lib/mobile-diagram.ts @@ -1,11 +1,14 @@ import { hasDiagramDocumentMarker, + hasInfographicDocumentMarker, hasTableDocumentMarker, markdownToDoc, parseDiagramDocument, + parseInfographicDocument, parseTableDocument, resolveMemoContentDoc, stripDiagramDocumentMarker, + stripInfographicDocumentMarker, stripTableDocumentMarker, tableFallbackMarkdown, type TiptapDoc, @@ -21,6 +24,9 @@ export const hasMobileVisualDiagram = (contentMarkdown: string) => export const hasMobileStructuredTable = (contentMarkdown: string) => hasTableDocumentMarker(contentMarkdown); +export const hasMobileInfographic = (contentMarkdown: string) => + hasInfographicDocumentMarker(contentMarkdown); + /** Viewer TipTap payload for a visual-diagram envelope. Valid IR is drawn by read-only X6, so this returns an empty doc instead of a hidden Mermaid projection. Invalid envelopes keep the stripped Mermaid fence as degraded content. */ export const resolveMobileMemoViewerContent = ( contentJson: TiptapDoc | null | undefined, @@ -32,6 +38,10 @@ export const resolveMobileMemoViewerContent = ( if (hasDiagramDocumentMarker(contentMarkdown)) { return markdownToDoc(stripDiagramDocumentMarker(contentMarkdown)); } + const infographic = parseInfographicDocument(contentMarkdown); + if (infographic || hasInfographicDocumentMarker(contentMarkdown)) { + return markdownToDoc(stripInfographicDocumentMarker(contentMarkdown)); + } const table = parseTableDocument(contentMarkdown); if (table) return markdownToDoc(tableFallbackMarkdown(table)); if (hasTableDocumentMarker(contentMarkdown)) { diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index 12f7c30c..e54f872a 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -18,7 +18,7 @@ import { MobileAiAssistantModal } from "../components/MobileAiAssistantModal"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; import { getNextMobileNoteSearchIndex } from "../lib/mobile-note-search"; -import { hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram"; +import { hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram"; import { safeDomCall } from "../lib/safe-dom-call"; import { getMobileImageTarget, @@ -528,7 +528,7 @@ export const MemoDetailModal = ({ () => (memo ? hasMobileStructuredTable(memo.contentMarkdown) : false), [memo] ); - const locksRichTextEdit = isVisualDiagram || isStructuredTable; + const locksRichTextEdit = isVisualDiagram || isStructuredTable || Boolean(memo && hasMobileInfographic(memo.contentMarkdown)); const visualDiagramJson = useMemo(() => { const diagram = memo ? parseDiagramDocument(memo.contentMarkdown) : null; return diagram ? JSON.stringify(diagram) : undefined; diff --git a/apps/web/package.json b/apps/web/package.json index b9975f8f..8621a4c4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,12 +4,13 @@ "private": true, "type": "module", "dependencies": { + "@antv/infographic": "0.2.20", "@antv/x6": "3.1.8", "@codemirror/lang-markdown": "^6.5.2", - "@lezer/highlight": "^1.2.3", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/noto-sans-sc": "^5.2.8", + "@lezer/highlight": "^1.2.3", "@tiptap/extension-code-block-lowlight": "3.31.0", "@tiptap/pm": "3.31.0", "@uiw/codemirror-themes-all": "^4.25.11", diff --git a/apps/web/src/components/InfographicEditorPane.tsx b/apps/web/src/components/InfographicEditorPane.tsx new file mode 100644 index 00000000..adc86616 --- /dev/null +++ b/apps/web/src/components/InfographicEditorPane.tsx @@ -0,0 +1,385 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronLeft, Download, LoaderCircle, Presentation, Sparkles, Undo2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { markdownToDoc, infographicFallbackMarkdown, parseInfographicDocument, serializeInfographicDocument, type InfographicDocument, type MemoDetail, type MemoEditSession } from "@edgeever/shared"; +import type { Infographic as InfographicInstance, SyntaxParseResult } from "@antv/infographic"; +import { Button } from "@/components/ui/button"; +import { MemoTitleInput } from "@/components/MemoTitleInput"; +import { api } from "@/lib/api"; +import { createLocalEditSession, requiresLocalEditSession } from "@/components/editor/editor-pane-helpers"; +import { buildInfographicSyntax, buildOfficialInfographicSyntax, generatedInfographicSyntax, inferInfographicKind, infographicTemplatePrompt, INFOGRAPHIC_TEMPLATES, officialTemplateFamily, parseGeneratedInfographicContent, parseGeneratedOfficialData, parseGeneratedOfficialSelection, sampleOfficialData, shortlistOfficialTemplates, shouldReplaceExistingInfographic, templateForRequest, type InfographicItem } from "@/lib/infographic-generation"; +import type { EdgeEverRepository } from "@/lib/repository"; + +type Props = { + memo: MemoDetail; + repository: EdgeEverRepository; + readOnly: boolean; + onBackToList: () => void; + onSaved: (memo: MemoDetail) => Promise; +}; + +const plainLine = (value: string) => value.replace(/\s+/g, " ").trim(); +const parseItemLine = (line: string) => { + const [label, description] = line.split("|").map(plainLine); + return { label, description }; +}; +const parseFormItems = (template: string, text: string): InfographicItem[] => { + if (!template.startsWith("compare-binary-")) { + return text.split("\n").map(parseItemLine).filter((item) => item.label); + } + const roots: Array<{ label: string; description?: string; children: Array<{ label: string; description?: string }> }> = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const child = /^\s+-\s+(.+)$/.exec(line); + if (child) { + const item = parseItemLine(child[1]); + if (roots.length && item.label) roots[roots.length - 1].children.push(item); + } else { + const item = parseItemLine(line); + if (item.label) roots.push({ ...item, children: [] }); + } + } + return roots; +}; +const formatFormItems = (template: string, items: InfographicItem[]) => + template.startsWith("compare-binary-") + ? items.flatMap((item) => [ + item.description ? `${item.label} | ${item.description}` : item.label, + ...(item.children ?? []).map((child) => ` - ${child.label}${child.description ? ` | ${child.description}` : ""}`), + ]).join("\n") + : items.map((item) => item.description ? `${item.label} | ${item.description}` : item.label).join("\n"); +const buildSyntax = (template: string, title: string, description: string, items: string, dark: boolean) => + buildInfographicSyntax({ + template, title, description, dark, + items: parseFormItems(template, items), + }); + +const parseSimpleForm = (syntax: string) => { + const lines = syntax.trim().split("\n"); + const template = lines[0]?.match(/^infographic ([\w-]+)$/)?.[1]; + if (!template || !INFOGRAPHIC_TEMPLATES.some((item) => item.id === template)) return null; + const dark = lines[1] === "theme dark"; + const dataIndex = dark ? 2 : 1; + if (lines[dataIndex] !== "data") return null; + let heading = ""; + let description = ""; + const items: string[] = []; + let inList = false; + const comparison = template.startsWith("compare-binary-"); + let comparisonChildIndex = 0; + let comparisonRootIndex = -1; + for (const line of lines.slice(dataIndex + 1)) { + if (line.startsWith(" title ") && !inList) heading = line.slice(8); + else if (line.startsWith(" desc ") && !inList) description = line.slice(7); + else if (line === (template.startsWith("sequence-") ? " sequences" : comparison || /^(compare-)?quadrant-/.test(template) ? " compares" : " lists")) inList = true; + else if (comparison && line.startsWith(" - label ") && inList) { + comparisonRootIndex = items.push(line.slice(12)) - 1; + comparisonChildIndex = 0; + } + else if (comparison && line === " children" && inList) continue; + else if (comparison && line.startsWith(" - label ") && inList) { + if (comparisonChildIndex > 0) items.push(` - ${line.slice(16)}`); + comparisonChildIndex += 1; + } + else if (comparison && line.startsWith(" desc ") && inList && items.length && comparisonChildIndex > 0) { + const target = comparisonChildIndex === 1 ? comparisonRootIndex : items.length - 1; + items[target] += ` | ${line.slice(15)}`; + } + else if (!comparison && line.startsWith(" - label ") && inList) items.push(line.slice(12)); + else if (!comparison && line.startsWith(" desc ") && inList && items.length) items[items.length - 1] += ` | ${line.slice(11)}`; + else return null; + } + const itemText = items.join("\n"); + return buildSyntax(template, heading, description, itemText, dark) === syntax.trim() + ? { template, heading, description, items: itemText, dark } + : null; +}; + +type VisualTextChange = { + changes?: Array<{ path: string; indexes?: number[]; value?: unknown }>; +}; + +const applyVisualTextChange = (syntax: string, payload: VisualTextChange) => { + const form = parseSimpleForm(syntax); + if (!form || !payload.changes?.length) return null; + const formItems = parseFormItems(form.template, form.items); + let changed = false; + for (const change of payload.changes) { + if (typeof change.value === "string" && change.path === "data.title") { + form.heading = plainLine(change.value); + } else if (typeof change.value === "string" && change.path === "data.desc") { + form.description = plainLine(change.value); + } else if (change.path === "data.items" && change.indexes && change.value && typeof change.value === "object") { + const comparison = form.template.startsWith("compare-binary-"); + if (comparison ? change.indexes.length !== 2 : change.indexes.length !== 1) return null; + const index = change.indexes[0]; + if (!Number.isInteger(index) || index < 0 || index >= formItems.length) return null; + const value = change.value as Record; + const childIndex = comparison ? change.indexes[1] : 0; + const item = comparison && childIndex > 0 ? formItems[index].children?.[childIndex - 1] : formItems[index]; + if (!item) return null; + const label = typeof value.label === "string" ? plainLine(value.label) : item.label; + const description = typeof value.desc === "string" ? plainLine(value.desc) : item.description; + if (!label) return null; + item.label = label; + item.description = description; + } else { + return null; + } + changed = true; + } + return changed ? buildSyntax(form.template, form.heading, form.description, formatFormItems(form.template, formItems), form.dark) : null; +}; + +const editableOfficialData = (syntax: string, parsed: SyntaxParseResult) => { + if (parsed.errors.length || !parsed.options.template || !parsed.options.data) return null; + const data = parsed.options.data as Record; + const dark = syntax.split("\n")[1] === "theme dark"; + return buildOfficialInfographicSyntax(parsed.options.template, data, dark) === syntax.trim() ? { data, dark } : null; +}; + +const applyOfficialVisualTextChange = (syntax: string, payload: VisualTextChange, parsed: SyntaxParseResult) => { + const editable = editableOfficialData(syntax, parsed); + if (!editable || !payload.changes?.length || !parsed.options.template) return null; + const data = structuredClone(editable.data); + const items = (data.lists ?? data.sequences ?? data.compares ?? data.nodes ?? data.values ?? (data.root ? [data.root] : data.items)) as Array> | undefined; + for (const change of payload.changes) { + if (typeof change.value === "string" && change.path === "data.title") data.title = plainLine(change.value); + else if (typeof change.value === "string" && change.path === "data.desc") data.desc = plainLine(change.value); + else if (change.path === "data.items" && change.indexes?.length && change.value && typeof change.value === "object" && items) { + let item: Record | undefined = items[change.indexes[0]]; + for (const index of change.indexes.slice(1)) item = (item?.children as Array> | undefined)?.[index]; + if (!item) return null; + const value = change.value as Record; + if (typeof value.label === "string") item.label = plainLine(value.label); + if (typeof value.desc === "string") item.desc = plainLine(value.desc); + if (typeof value.value === "number" && Number.isFinite(value.value)) item.value = value.value; + } else return null; + } + return buildOfficialInfographicSyntax(parsed.options.template, data, editable.dark); +}; + +const downloadDataUrl = (dataUrl: string, filename: string) => { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +export default function InfographicEditorPane({ memo, repository, readOnly, onBackToList, onSaved }: Props) { + const { t, i18n } = useTranslation(); + const parsed = useMemo(() => parseInfographicDocument(memo.contentMarkdown), [memo.contentMarkdown]); + const [title, setTitle] = useState(memo.title ?? ""); + const [syntax, setSyntax] = useState(parsed?.syntax ?? ""); + const [prompt, setPrompt] = useState(""); + const [previousGeneration, setPreviousGeneration] = useState<{ title: string; syntax: string } | null>(null); + const [generating, setGenerating] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [renderError, setRenderError] = useState(null); + const [previewReady, setPreviewReady] = useState(false); + const [directEditAvailable, setDirectEditAvailable] = useState(false); + const [ready, setReady] = useState(false); + const [savedSnapshot, setSavedSnapshot] = useState(JSON.stringify([memo.title ?? "", parsed?.syntax ?? ""])); + const containerRef = useRef(null); + const instanceRef = useRef(null); + const sessionRef = useRef(null); + const memoRef = useRef(memo); + const saveRef = useRef<() => void>(() => undefined); + const snapshot = JSON.stringify([title, syntax]); + const dirty = snapshot !== savedSnapshot; + + useEffect(() => { + memoRef.current = memo; + }, [memo]); + + useEffect(() => { + if (readOnly) return; + let cancelled = false; + if (requiresLocalEditSession(memo)) { + sessionRef.current = createLocalEditSession(memo); + setReady(true); + return; + } + void api.createMemoEditSession(memo.id).then(({ editSession }) => { + if (!cancelled) { sessionRef.current = editSession; setReady(true); } + }).catch(() => { if (!cancelled) setError(t("infographic.sessionError")); }); + return () => { cancelled = true; }; + }, [memo.id, readOnly, t]); + + useEffect(() => { + setPreviewReady(false); + if (!syntax.trim()) { instanceRef.current?.destroy(); instanceRef.current = null; setRenderError(null); setDirectEditAvailable(false); return; } + let cancelled = false; + const timer = window.setTimeout(() => { + void import("@antv/infographic").then(({ Infographic, DblClickEditText, parseSyntax, getTemplate }) => { + if (cancelled || !containerRef.current) return; + const parsedSyntax = parseSyntax(syntax); + if (parsedSyntax.errors.length || !parsedSyntax.options.template || !getTemplate(parsedSyntax.options.template)) { + setRenderError(parsedSyntax.errors[0]?.message ?? t("infographic.invalidSyntax")); + setDirectEditAvailable(false); + instanceRef.current?.destroy(); instanceRef.current = null; + return; + } + try { + instanceRef.current?.destroy(); + const visualTextEditable = !readOnly && Boolean(parseSimpleForm(syntax) || editableOfficialData(syntax, parsedSyntax)); + setDirectEditAvailable(visualTextEditable); + const instance = new Infographic({ + container: containerRef.current, width: "100%", height: "100%", + editable: visualTextEditable, + ...(visualTextEditable ? { interactions: [new DblClickEditText()], plugins: [] } : {}), + }); + if (visualTextEditable) instance.on("options:change", (payload: VisualTextChange) => { + const nextSyntax = applyVisualTextChange(syntax, payload) ?? applyOfficialVisualTextChange(syntax, payload, parsedSyntax); + if (!nextSyntax || nextSyntax === syntax) return; + if (parseSyntax(nextSyntax).errors.length) return; + setPreviousGeneration(null); + setSyntax(nextSyntax); + }); + instance.render(syntax); + instanceRef.current = instance; + setRenderError(null); + setPreviewReady(true); + } catch (caught) { + setRenderError(caught instanceof Error ? caught.message : t("infographic.renderError")); + instanceRef.current = null; + } + }).catch(() => { if (!cancelled) setRenderError(t("infographic.renderError")); }); + }, 250); + return () => { cancelled = true; window.clearTimeout(timer); }; + }, [syntax, readOnly, t]); + + useEffect(() => () => { instanceRef.current?.destroy(); }, []); + + const undoGeneration = () => { + if (!previousGeneration) return; + setSyntax(previousGeneration.syntax); + setTitle(previousGeneration.title); + setPreviousGeneration(null); + setError(null); + }; + + const save = async () => { + if (readOnly || saving || !dirty || !ready || !sessionRef.current) return; + if (syntax.trim() && (renderError || !previewReady || !instanceRef.current)) { + setError(renderError ?? t("infographic.invalidSyntax")); + return; + } + const currentMemo = memoRef.current; + const currentSnapshot = snapshot; + const document: InfographicDocument = { schemaVersion: 1, syntax }; + setSaving(true); setError(null); + try { + const result = await repository.updateMemo(currentMemo, { + expectedRevision: currentMemo.revision, + expectedContentHash: currentMemo.contentHash, + editSessionId: sessionRef.current.id, + title, + contentJson: markdownToDoc(infographicFallbackMarkdown(document)), + contentMarkdown: serializeInfographicDocument(document), + tags: currentMemo.tags, + }); + memoRef.current = result.memo; + setSavedSnapshot(currentSnapshot); + await onSaved(result.memo); + } catch (caught) { + setError(caught instanceof Error ? caught.message : t("infographic.saveError")); + } finally { setSaving(false); } + }; + saveRef.current = () => { void save(); }; + + useEffect(() => { + if (!dirty || !ready || readOnly || saving || generating || renderError || (syntax.trim() && !previewReady)) return; + const timer = window.setTimeout(() => saveRef.current(), 1200); + return () => window.clearTimeout(timer); + }, [dirty, ready, readOnly, saving, generating, renderError, previewReady, snapshot, syntax]); + + const generate = async () => { + if (!prompt.trim() || generating || readOnly) return; + setGenerating(true); setError(null); + let output = ""; + try { + const currentForm = parseSimpleForm(syntax); + const currentKind = INFOGRAPHIC_TEMPLATES.find((item) => item.id === currentForm?.template)?.kind; + const replaceExisting = shouldReplaceExistingInfographic(prompt, currentKind); + const requestedKind = inferInfographicKind(prompt) ?? (replaceExisting ? null : currentKind); + const { parseSyntax, getTemplate, getTemplates } = await import("@antv/infographic"); + const availableTemplates = getTemplates(); + const existingOptions = parseSyntax(syntax).options; + const existingTemplate = existingOptions.template; + const namedTemplate = availableTemplates.find((id) => prompt.includes(id)); + const inferredTemplate = templateForRequest(prompt); + const requestedFamily = requestedKind === "steps" || requestedKind === "timeline" ? "sequence" : requestedKind; + const asksForDifferentTemplate = /(换成|改成|改用|换个|另一种|更适合的模板|模板|版式|布局|template|layout|style)/i.test(prompt); + const existingOfficial = existingTemplate && availableTemplates.includes(existingTemplate) && !INFOGRAPHIC_TEMPLATES.some((item) => item.id === existingTemplate) + && !asksForDifferentTemplate && (!requestedFamily || officialTemplateFamily(existingTemplate) === requestedFamily) ? existingTemplate : null; + const officialTarget = namedTemplate ?? inferredTemplate ?? existingOfficial; + const catalogAvailable = availableTemplates.length > 0 && !officialTarget; + const catalogCandidates = catalogAvailable ? shortlistOfficialTemplates(prompt, availableTemplates, existingTemplate) : []; + const currentContent = currentForm && !replaceExisting ? JSON.stringify({ + kind: currentKind, template: currentForm.template, title: currentForm.heading, description: currentForm.description, + items: parseFormItems(currentForm.template, currentForm.items), + }) : ""; + const officialCurrentData = officialTarget === existingTemplate && existingOptions.data && !/(生成|重做|重新)/i.test(prompt) + ? JSON.stringify(existingOptions.data) : ""; + const officialInstruction = officialTarget ? `User request: ${prompt.trim()}. Use the exact built-in AntV Infographic template "${officialTarget}". Its data family is "${officialTemplateFamily(officialTarget)}". ${officialCurrentData ? `Revise this current data, keeping parts the user did not ask to change: ${officialCurrentData.slice(0, 300)}.` : "Create new data."} Return ONLY valid JSON with a "data" object. Follow this example's keys and nesting, but replace the example content with relevant content: ${JSON.stringify(sampleOfficialData(officialTarget))}. For chart values use finite numeric values; for relation nodes give unique ids and only connect existing ids; for hierarchy use a root with nested children; for quadrants return exactly four compares; for binary comparison return exactly two compares with matched children. Keep labels and descriptions short enough to fit. Do not return AntV syntax, Markdown, or commentary.` : ""; + const catalogInstruction = catalogAvailable ? `Request: ${prompt.trim()}. Choose the best AntV Infographic template from these installed candidates: ${catalogCandidates.join(", ")}. Return ONLY JSON: {"template":"one exact candidate ID","data":{...}}. Data example for this family: ${JSON.stringify(sampleOfficialData(catalogCandidates[0]))}. Replace sample content with useful content in the user's language. ${existingTemplate && existingOptions.data ? `Current: ${existingTemplate}, ${JSON.stringify(existingOptions.data).slice(0, 180)}. Preserve its substance for a small edit; change template when the request calls for a different kind of graphic.` : ""} Chart values must be numbers. Relation node IDs must be unique, with relations between existing IDs. Quadrant and SWOT need four compares; binary comparison needs two. Keep text concise. No Markdown.` : ""; + await api.streamAiGeneration({ + action: "custom", title: "", contentMarkdown: "", stream: true, attachments: [], locale: i18n.resolvedLanguage, + instruction: (officialTarget ? officialInstruction : catalogAvailable ? catalogInstruction : `${currentContent ? `Revise this existing infographic content according to the user request. Keep details and template the user did not ask to change. Current content: ${currentContent}\n` : "Create a new infographic for the user request. Choose its structure based on the new request, independently of any previous infographic.\n"}User request: ${prompt.trim()}\nReturn ONLY one valid JSON object, with keys: "kind", "template", "title", "description", "items". "kind" must be one of "steps", "list", "timeline", "comparison", "quadrant".${requestedKind ? ` The requested kind is "${requestedKind}"; use it.` : " Choose the best kind for this request."} Select "template" from these built-in AntV templates, matching its kind and the user's intent: ${infographicTemplatePrompt()}. Use a shorter layout for few items and a denser layout for many items. "items" must be a JSON array of objects; each object must have "label" and "description" strings. For "quadrant", return exactly four items. For "comparison", return exactly two items, one per thing being compared. Each comparison item must have a short description and a "children" array of two or three matched comparison aspects; each child needs a short "label" and "description". Keep every comparison description under 24 Chinese characters (or 48 Latin letters) so it fits a card. If the request has no detailed data, invent a useful, clearly generic example. Use the request's language. Do not return AntV syntax, Markdown fences, explanations, or comments.`).slice(0, 2000), + }, { onEvent: (event) => { if (event.type === "text-delta") output += event.text; if (event.type === "error") throw new Error(event.message); } }); + const officialData = officialTarget ? parseGeneratedOfficialData(output, officialTarget) : null; + const catalogSelection = catalogAvailable ? parseGeneratedOfficialSelection(output, catalogCandidates) : null; + const selectedOfficial = officialTarget && officialData ? { template: officialTarget, data: officialData } : catalogSelection; + const content = officialTarget || catalogAvailable ? null : parseGeneratedInfographicContent(output, prompt); + if (!selectedOfficial && !content) throw new Error(t("infographic.aiInvalidResponse")); + if (content && currentForm && !replaceExisting && !/(模板|版式|风格|样式|布局|紧凑|圆形|金字塔|网格|路线图|里程碑|编号|交错|template|layout|style|compact|circular|pyramid|grid|roadmap|milestone|numbered|zigzag)/i.test(prompt)) content.template = currentForm.template; + const candidate = selectedOfficial ? buildOfficialInfographicSyntax(selectedOfficial.template, selectedOfficial.data) : generatedInfographicSyntax(content!); + const parsedCandidate = parseSyntax(candidate); + if (parsedCandidate.errors.length || !parsedCandidate.options.template || !getTemplate(parsedCandidate.options.template)) { + throw new Error(t("infographic.aiInvalidResponse")); + } + setPreviousGeneration({ title, syntax }); + setSyntax(candidate); + const generatedTitle = selectedOfficial ? String(selectedOfficial.data.title ?? "") : content!.title; + if (!title.trim() || title.trim() === t("infographic.name") || (replaceExisting && title.trim() === currentForm?.heading)) setTitle(generatedTitle); + setPrompt(""); + } catch (caught) { setError(caught instanceof Error ? caught.message : t("infographic.aiError")); } + finally { setGenerating(false); } + }; + + const exportImage = async (type: "svg" | "png") => { + if (!instanceRef.current || renderError || !previewReady) return; + try { + const url = await instanceRef.current.toDataURL({ type }); + downloadDataUrl(url, `${(title.trim() || "infographic").replace(/[\\/:*?"<>|]/g, "-")}.${type}`); + } catch (caught) { setError(caught instanceof Error ? caught.message : t("infographic.renderError")); } + }; + + return
+
+ + +
+ {readOnly ? {t("infographic.readOnly")} : } + + +
+ {error ?

{error}

: null} +
+
+ {!readOnly &&
+ +