feat: add AI-driven infographic notes

This commit is contained in:
tianma-if committed 2026-09-24 21:19:23 +08:00
1 parent 608bb0a697
commit c3de2cd102
25 files changed
+1133 -15

No files matched your search

+2
View File
@@ -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),
@@ -32,6 +32,10 @@ enum TipTapContentSource: Sendable {
let stripped = stripStructuredTableMarker(markdown)
return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)")
}
if markdown.contains("<!-- edgeever-infographic-v1:") {
let stripped = stripInfographicMarker(markdown)
return Decision(useJSON: false, payload: stripped, fingerprint: "infographic:\(stripped)")
}
if jsonUsable {
if jsonHasImageWidth(json) {
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
@@ -67,6 +71,14 @@ enum TipTapContentSource: Sendable {
).trimmingCharacters(in: .whitespacesAndNewlines)
}
static func stripInfographicMarker(_ markdown: String) -> String {
markdown.replacingOccurrences(
of: #"<!--\s*edgeever-infographic-v1:[\s\S]*?-->"#,
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.
@@ -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("<!-- edgeever-table-v1:")
}
private func isInfographic(_ memo: MemoDetail) -> Bool {
memo.contentMarkdown.contains("<!-- edgeever-infographic-v1:")
}
private func blocksRichTextEdit(_ memo: MemoDetail) -> Bool {
isVisualDiagram(memo) || isStructuredTable(memo)
isVisualDiagram(memo) || isStructuredTable(memo) || isInfographic(memo)
}
private func refreshSyncStatus() {
@@ -25,6 +25,18 @@ final class TipTapContentSourceTests: XCTestCase {
XCTAssertTrue(decision.payload.contains("示例"))
}
func testViewerStripsInfographicMarkerAndKeepsReadableSyntax() {
let markdown = "```infographic\ninfographic list-row-simple-horizontal-arrow\ndata\n title Test\n```\n\n<!-- edgeever-infographic-v1:abc -->"
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
+10 -2
View File
@@ -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; }");
+10
View File
@@ -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)) {
@@ -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;
+2 -1
View File
@@ -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",
@@ -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<void>;
};
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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>> | 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<string, unknown> | undefined = items[change.indexes[0]];
for (const index of change.indexes.slice(1)) item = (item?.children as Array<Record<string, unknown>> | undefined)?.[index];
if (!item) return null;
const value = change.value as Record<string, unknown>;
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<string | null>(null);
const [renderError, setRenderError] = useState<string | null>(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<HTMLDivElement>(null);
const instanceRef = useRef<InfographicInstance | null>(null);
const sessionRef = useRef<MemoEditSession | null>(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 <div className="flex h-full min-h-0 flex-col bg-white">
<header className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-4 py-3">
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onBackToList} aria-label={t("common.back")}><ChevronLeft className="h-4 w-4" /></Button>
<Presentation className="h-5 w-5 text-emerald-700" />
<div className="min-w-40 flex-1"><MemoTitleInput value={title} onValueChange={setTitle} placeholder={t("infographic.name")} readOnly={readOnly} /></div>
{readOnly ? <span className="text-xs text-slate-500">{t("infographic.readOnly")}</span> : <Button size="sm" disabled={!dirty || !ready || saving || Boolean(renderError) || (Boolean(syntax.trim()) && !previewReady)} onClick={() => void save()}>{saving ? t("infographic.saving") : t("infographic.save")}</Button>}
<Button variant="outline" size="sm" disabled={!previewReady || Boolean(renderError)} onClick={() => void exportImage("svg")}><Download className="mr-1 h-4 w-4" />{t("infographic.exportSvg")}</Button>
<Button variant="outline" size="sm" disabled={!previewReady || Boolean(renderError)} onClick={() => void exportImage("png")}>{t("infographic.exportPng")}</Button>
</header>
{error ? <p role="alert" className="border-b border-red-100 bg-red-50 px-5 py-2 text-sm text-red-700">{error}</p> : null}
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(300px,34%)_1fr]">
<section className="min-h-0 overflow-y-auto border-b border-slate-200 p-5 lg:border-b-0 lg:border-r">
{!readOnly && <div className="mb-5 rounded-xl border border-emerald-100 bg-emerald-50/50 p-4">
<label className="mb-2 block text-sm font-medium text-slate-800" htmlFor="infographic-prompt"><Sparkles className="mr-1 inline h-4 w-4 text-emerald-700" />{t(syntax.trim() ? "infographic.refine" : "infographic.describe")}</label>
<textarea id="infographic-prompt" maxLength={1000} className="min-h-24 w-full rounded-md border border-slate-200 bg-white p-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-emerald-500" placeholder={t(syntax.trim() ? "infographic.refinePrompt" : "infographic.prompt")} value={prompt} onChange={(event) => setPrompt(event.target.value)} />
<div className="mt-2 flex flex-wrap items-center gap-2"><Button size="sm" disabled={!prompt.trim() || generating} onClick={() => void generate()}>{generating ? <LoaderCircle className="mr-1 h-4 w-4 animate-spin" /> : <Sparkles className="mr-1 h-4 w-4" />}{generating ? t("infographic.generating") : t(syntax.trim() ? "infographic.applyRefinement" : "infographic.generate")}</Button>
{previousGeneration && <Button size="sm" variant="outline" onClick={undoGeneration}><Undo2 className="mr-1 h-4 w-4" />{t("infographic.undoGeneration")}</Button>}
</div>
</div>}
</section>
<section className="min-h-0 overflow-auto bg-slate-50 p-4"><h2 className="mb-1 text-sm font-medium text-slate-600">{t("infographic.preview")}</h2>{directEditAvailable && <p className="mb-3 text-xs text-slate-500">{t("infographic.doubleClickText")}</p>}<div className="min-h-[420px] rounded-xl border border-slate-200 bg-white p-4 shadow-sm"><div ref={containerRef} className="min-h-[380px] w-full" />{!syntax.trim() && <p className="pt-32 text-center text-sm text-slate-400">{t("infographic.noPreview")}</p>}{renderError && <p role="alert" className="text-sm text-red-600">{renderError}</p>}</div></section>
</div>
</div>;
}
+3 -1
View File
@@ -1,7 +1,7 @@
import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { GitBranch, Network, TableProperties, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { GitBranch, Network, Presentation, TableProperties, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { getMemoListTimestamp, type MemoSummary } from "@edgeever/shared";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -414,6 +414,8 @@ export const MemoCard = ({
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600 ">{memo.diagramPreview.labels.join(" · ")}</div>
) : null}
</div>
) : memo.infographic ? (
<div className="flex items-center gap-1 text-xs text-slate-500"><Presentation className="h-3 w-3" aria-hidden="true" />{t("infographic.name")}</div>
) : tableLabel ? (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
+6
View File
@@ -11,6 +11,7 @@ import {
LayoutGrid,
LayoutList,
LayoutTemplate,
Presentation,
BookPlus,
Boxes,
ArrowDownWideNarrow,
@@ -322,6 +323,11 @@ const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: NoteCreat
<Boxes className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("diagram.architecture")}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("infographic")}>
<Presentation className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("infographic.name")}</span>
<span className="text-xs text-slate-400">{t("infographic.autoLayout")}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
<TableProperties className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
+35 -2
View File
@@ -46,7 +46,7 @@ import {
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, markdownToDoc, parseDiagramDocument, parseTableDocument, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, infographicFallbackMarkdown, markdownToDoc, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -140,6 +140,7 @@ import { findMatchingMemoResource } from "@/lib/staged-resource-repair";
const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane })));
const DiagramEditorPane = lazy(() => import("./DiagramEditorPane"));
const TableEditorPane = lazy(() => import("./TableEditorPane"));
const InfographicEditorPane = lazy(() => import("./InfographicEditorPane"));
const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane })));
const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane })));
const PluginMarketplacePane = lazy(() => import("./PluginMarketplacePane").then((module) => ({ default: module.PluginMarketplacePane })));
@@ -1233,7 +1234,7 @@ export const WorkspaceApp = ({
const revealCreatedMemo = (memo: MemoDetail) => {
const targetNotebookId = memo.notebookId;
const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown));
const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown) || parseInfographicDocument(memo.contentMarkdown));
setMemoView("notebook");
setSearch("");
@@ -1622,6 +1623,7 @@ export const WorkspaceApp = ({
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown);
const selectedTableNote = hasTableDocumentMarker(selectedMemo?.contentMarkdown);
const selectedInfographicNote = Boolean(parseInfographicDocument(selectedMemo?.contentMarkdown));
const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed);
const desktopFocusModeActive = Boolean(
isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive
@@ -1911,6 +1913,17 @@ export const WorkspaceApp = ({
setTemplatesOpen(false);
setMobileBottomNavActive("home");
creatingMemoSelectionRef.current = true;
if (kind === "infographic") {
const infographic = createDefaultInfographicDocument();
createMemoMutation.mutate({
notebookId: targetNotebookId,
title: t("infographic.name"),
contentJson: markdownToDoc(infographicFallbackMarkdown(infographic)),
contentMarkdown: serializeInfographicDocument(infographic),
tags: [],
});
return;
}
if (kind === "table") {
const table = createDefaultTableDocument({
name: t("structuredTable.defaultFields.name"),
@@ -3380,6 +3393,26 @@ export const WorkspaceApp = ({
onOpenExecutionCenter={handleOpenExecutionCenter}
/>
) : selectedMemo && selectedInfographicNote ? (
<InfographicEditorPane
key={selectedMemo.id}
memo={selectedMemo}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
onBackToList={() => {
clearPendingCreatedMemo();
setActivePane("memos");
}}
onSaved={async (memo) => {
await putLocalMemo(localDataScope, memo);
cacheMemoDetail(queryClient, memo, memoView);
updateMemoSummaryInLists(queryClient, memoToSummary(memo));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }),
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }),
]);
}}
/>
) : selectedMemo && selectedTableNote ? (
<TableEditorPane
key={selectedMemo.id}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, TableProperties, UserRound, Workflow, X } from "lucide-react";
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Presentation, Search, TableProperties, UserRound, Workflow, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -104,6 +104,10 @@ export const MobileBottomNav = ({
<Boxes className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("diagram.architecture")}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("infographic")}>
<Presentation className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("infographic.name")}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
<TableProperties className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test";
import { parseSyntax, getTemplate, getTemplates } from "@antv/infographic";
import {
buildInfographicSyntax,
buildOfficialInfographicSyntax,
generatedInfographicSyntax,
inferInfographicKind,
officialTemplateFamily,
parseGeneratedInfographicContent,
parseGeneratedOfficialData,
parseGeneratedOfficialSelection,
sampleOfficialData,
selectableInfographicTemplates,
shortlistOfficialTemplates,
shouldReplaceExistingInfographic,
templateForRequest,
} from "./infographic-generation.ts";
describe("infographic generation", () => {
test("a four-quadrant request produces AntV's compares array", () => {
const output = '```json\n{"kind":"steps","title":"优先级四象限","items":[{"label":"立即做","description":"重要且紧急"},{"label":"计划做","description":"重要不紧急"},{"label":"委派","description":"紧急不重要"},{"label":"不做","description":"不重要不紧急"}]}\n```';
const content = parseGeneratedInfographicContent(output, "给我生成一个四象限图");
expect(inferInfographicKind("给我生成一个四象限图")).toBe("quadrant");
expect(content?.kind).toBe("quadrant");
const syntax = generatedInfographicSyntax(content);
expect(syntax).toContain("infographic compare-quadrant-quarter-simple-card\ndata\n");
expect(syntax).toContain(" compares\n - label 立即做");
const parsed = parseSyntax(syntax);
expect(parsed.errors).toEqual([]);
expect(getTemplate(parsed.options.template)).toBeTruthy();
});
test("offered styles are real AntV templates and match their data family", () => {
const official = new Set(getTemplates());
for (const { id: template, kind } of selectableInfographicTemplates) {
expect(official.has(template)).toBe(true);
const items = kind === "quadrant" ? Array.from({ length: 4 }, (_, index) => ({ label: `第${index + 1}项`, description: "说明" }))
: kind === "comparison" ? [{ label: "A", children: [{ label: "维度", description: "说明" }] }, { label: "B", children: [{ label: "维度", description: "说明" }] }]
: [{ label: "第一项", description: "说明" }];
const syntax = buildInfographicSyntax({ template, title: "测试", items });
expect(parseSyntax(syntax).errors).toEqual([]);
}
});
test("the model can choose a compatible official style but cannot cross data families", () => {
const request = "生成一个字节和腾讯的对比图";
const data = { kind: "comparison", template: "compare-binary-horizontal-compact-card-vs", title: "对比", items: [{ label: "字节" }, { label: "腾讯" }] };
expect(parseGeneratedInfographicContent(JSON.stringify(data), request)?.template).toBe(data.template);
expect(parseGeneratedInfographicContent(JSON.stringify({ ...data, template: "sequence-steps-simple" }), request)?.template).toBe("compare-binary-horizontal-badge-card-vs");
expect(parseGeneratedInfographicContent(JSON.stringify({ ...data, items: ["A", "B", "C", "D"] }), "换成圆形四象限图")?.template).toBe("compare-quadrant-quarter-circular");
expect(parseGeneratedInfographicContent(JSON.stringify({ ...data, kind: "list" }), "生成金字塔清单")?.template).toBe("list-pyramid-compact-card");
});
test("a new comparison request replaces an existing quadrant with a side-by-side template", () => {
const request = "生成一个字节和腾讯的对比图";
expect(inferInfographicKind(request)).toBe("comparison");
expect(shouldReplaceExistingInfographic(request, "quadrant")).toBe(true);
expect(shouldReplaceExistingInfographic("把第二象限标题改短一点", "quadrant")).toBe(false);
expect(shouldReplaceExistingInfographic("换成紧凑对比图版式", "comparison")).toBe(false);
const output = JSON.stringify({ kind: "quadrant", title: "字节与腾讯", items: [
{ label: "字节跳动", description: "内容平台与推荐技术", children: [{ label: "主要产品", description: "抖音与今日头条" }, { label: "业务侧重", description: "短视频与广告" }] },
{ label: "腾讯", description: "社交平台与游戏业务", children: [{ label: "主要产品", description: "微信与QQ" }, { label: "业务侧重", description: "社交与游戏" }] },
] });
const content = parseGeneratedInfographicContent(output, request);
expect(content?.kind).toBe("comparison");
const syntax = generatedInfographicSyntax(content);
expect(syntax).toContain("infographic compare-binary-horizontal-badge-card-vs");
expect(syntax).toContain(" compares\n - label 字节跳动\n children\n - label 字节跳动");
expect(syntax).toContain(" - label 主要产品\n desc 抖音与今日头条");
const parsed = parseSyntax(syntax);
expect(parsed.errors).toEqual([]);
expect(getTemplate(parsed.options.template)).toBeTruthy();
});
test("malformed model content is rejected before reaching AntV", () => {
expect(parseGeneratedInfographicContent("Expected array value.", "四象限图")).toBeNull();
expect(parseGeneratedInfographicContent('{"kind":"quadrant","items":"wrong"}', "四象限图")).toBeNull();
expect(parseGeneratedInfographicContent('{"kind":"quadrant","items":[{"label":"A"}]}', "四象限图")).toBeNull();
});
test("all 276 installed AntV templates accept the shared family data format", () => {
const templates = getTemplates();
expect(templates).toHaveLength(276);
for (const template of templates) {
const sample = sampleOfficialData(template);
const syntax = buildOfficialInfographicSyntax(template, sample);
const parsed = parseSyntax(syntax);
expect(parsed.errors).toEqual([]);
expect(parsed.options.template).toBe(template);
expect(parsed.options.data).toEqual(sample);
expect(getTemplate(template)).toBeTruthy();
expect(["chart", "comparison", "hierarchy", "list", "quadrant", "relation", "sequence"]).toContain(officialTemplateFamily(template));
}
});
test("explicit chart and diagram requests resolve to installed templates", () => {
for (const request of ["饼图", "柱状图", "折线图", "词云", "思维导图", "组织结构", "网络图"]) {
expect(getTemplates()).toContain(templateForRequest(request));
}
});
test("AI data is checked for the chosen official template before rendering", () => {
const quadrant = "compare-quadrant-quarter-simple-card";
const data = sampleOfficialData(quadrant);
expect(parseGeneratedOfficialData(JSON.stringify({ data }), quadrant)).toEqual(data);
expect(parseGeneratedOfficialData(JSON.stringify({ data: { ...data, compares: data.compares.slice(0, 3) } }), quadrant)).toBeNull();
expect(parseGeneratedOfficialData(JSON.stringify({ data: { title: "坏图", values: [{ label: "A", value: "abc" }] } }), "chart-column-simple")).toBeNull();
expect(parseGeneratedOfficialData(JSON.stringify({ data: { title: "重复节点", nodes: [{ id: "a", label: "A" }, { id: "a", label: "B" }] } }), "relation-network-simple-circle-node")).toBeNull();
});
test("AI can select any installed template while an unknown ID is rejected", () => {
const templates = getTemplates();
const selected = templates.find((id) => id.startsWith("hierarchy-mindmap-"));
const data = sampleOfficialData(selected);
expect(parseGeneratedOfficialSelection(JSON.stringify({ template: selected, data }), templates)).toEqual({ template: selected, data });
expect(parseGeneratedOfficialSelection(JSON.stringify({ template: "made-up-template", data }), templates)).toBeNull();
});
test("AI shortlist uses the relevant family while every official ID remains directly selectable", () => {
const templates = getTemplates();
const comparison = shortlistOfficialTemplates("比较字节和腾讯的产品", templates);
expect(comparison.length).toBeLessThanOrEqual(12);
expect(comparison.every((id) => officialTemplateFamily(id) === "comparison")).toBe(true);
expect(comparison.every((id) => id.endsWith("-vs"))).toBe(true);
const mindmap = shortlistOfficialTemplates("画思维导图", templates);
expect(mindmap.every((id) => officialTemplateFamily(id) === "hierarchy")).toBe(true);
expect(templates).toContain("compare-quadrant-quarter-simple-card");
});
});
+303
View File
@@ -0,0 +1,303 @@
export const INFOGRAPHIC_TEMPLATES = [
{ id: "list-row-simple-horizontal-arrow", label: "simpleList", kind: "steps", hint: "short horizontal process" },
{ id: "sequence-steps-simple", label: "numberedSteps", kind: "steps", hint: "numbered steps" },
{ id: "sequence-horizontal-zigzag-simple", label: "zigzagSteps", kind: "steps", hint: "longer alternating process" },
{ id: "list-column-done-list", label: "verticalList", kind: "list", hint: "checklist" },
{ id: "list-grid-simple", label: "gridList", kind: "list", hint: "parallel ideas in a grid" },
{ id: "list-pyramid-compact-card", label: "pyramidList", kind: "list", hint: "ranked levels or priorities" },
{ id: "sequence-timeline-simple", label: "timeline", kind: "timeline", hint: "simple chronological events" },
{ id: "sequence-timeline-done-list", label: "timelineChecklist", kind: "timeline", hint: "chronological milestones" },
{ id: "sequence-roadmap-vertical-simple", label: "roadmap", kind: "timeline", hint: "vertical roadmap" },
{ id: "compare-binary-horizontal-badge-card-vs", label: "comparison", kind: "comparison", hint: "two subjects with matched aspects and VS divider" },
{ id: "compare-binary-horizontal-compact-card-vs", label: "compactComparison", kind: "comparison", hint: "dense two-sided comparison with VS divider" },
{ id: "compare-binary-horizontal-simple-arrow", label: "arrowComparison", kind: "comparison", hint: "before and after or directional comparison" },
{ id: "compare-quadrant-quarter-simple-card", label: "quadrant", kind: "quadrant", hint: "four labeled quadrants" },
{ id: "compare-quadrant-quarter-circular", label: "circularQuadrant", kind: "quadrant", hint: "four quadrants in a circular style" },
{ id: "quadrant-quarter-simple-card", label: "quadrant", kind: "quadrant", hint: "legacy four quadrants", selectable: false },
] as const;
export type InfographicKind = (typeof INFOGRAPHIC_TEMPLATES)[number]["kind"];
export type InfographicItem = { label: string; description?: string; children?: InfographicItem[] };
export type InfographicContent = {
kind: InfographicKind;
template: string;
title: string;
description?: string;
items: InfographicItem[];
};
export const selectableInfographicTemplates = INFOGRAPHIC_TEMPLATES.filter((item) => !("selectable" in item && item.selectable === false));
export const infographicTemplatePrompt = () => selectableInfographicTemplates
.map((item) => `${item.id} (${item.kind}: ${item.hint})`).join("; ");
export type OfficialTemplateFamily = "chart" | "comparison" | "hierarchy" | "list" | "quadrant" | "relation" | "sequence";
export const officialTemplateFamily = (id: string): OfficialTemplateFamily => {
if (/^(compare-)?quadrant-/.test(id) || id.startsWith("compare-quadrant-")) return "quadrant";
if (id.startsWith("compare-")) return "comparison";
if (id.startsWith("hierarchy-")) return "hierarchy";
if (id.startsWith("relation-")) return "relation";
if (id.startsWith("chart-")) return "chart";
if (id.startsWith("sequence-")) return "sequence";
return "list";
};
export const templateForRequest = (request: string): string | null => {
if (/(饼图|环形图|pie|donut)/i.test(request)) return "chart-pie-donut-plain-text";
if (/(柱状图|条形图|bar chart|column chart)/i.test(request)) return "chart-column-simple";
if (/(折线图|趋势图|line chart)/i.test(request)) return "chart-line-plain-text";
if (/(词云|word\s*cloud)/i.test(request)) return "chart-wordcloud";
if (/(思维导图|脑图|mind\s*map)/i.test(request)) return "hierarchy-mindmap-branch-gradient-lined-palette";
if (/(层级图|组织结构|树状图|hierarchy|org chart)/i.test(request)) return "hierarchy-tree-tech-style-capsule-item";
if (/(关系图|网络图|relation|network)/i.test(request)) return "relation-network-simple-circle-node";
return null;
};
export const sampleOfficialData = (template: string): Record<string, unknown> => {
const family = officialTemplateFamily(template);
const entry = (label: string, desc: string) => ({ label, desc });
if (family === "chart") return { title: "示例图表", values: [{ label: "项目 A", value: 40 }, { label: "项目 B", value: 30 }, { label: "项目 C", value: 20 }] };
if (family === "hierarchy") return { title: "示例层级", root: { label: "主题", children: [entry("分支 A", "说明"), entry("分支 B", "说明")] } };
if (family === "relation") return { title: "示例关系", nodes: [{ id: "a", label: "节点 A" }, { id: "b", label: "节点 B" }], relations: [{ from: "a", to: "b", direction: "forward" }] };
if (family === "comparison") {
const labels = template === "compare-swot" ? ["优势", "劣势", "机会", "威胁"] : ["方案 A", "方案 B"];
return { title: "示例对比", compares: labels.map((label) => ({ label, children: [entry("维度一", "简短说明"), entry("维度二", "简短说明")] })) };
}
if (family === "quadrant") return { title: "示例四象限", compares: ["第一象限", "第二象限", "第三象限", "第四象限"].map((label) => entry(label, "简短说明")) };
if (family === "sequence") return { title: "示例流程", sequences: [entry("第一步", "简短说明"), entry("第二步", "简短说明"), entry("第三步", "简短说明")] };
return { title: "示例列表", lists: [entry("项目 A", "简短说明"), entry("项目 B", "简短说明"), entry("项目 C", "简短说明")] };
};
const syntaxScalar = (value: string | number | boolean) => String(value).replace(/\s+/g, " ").trim();
const syntaxEntries = (value: Record<string, unknown>, indent: string): string[] => {
const lines: string[] = [];
for (const [key, entry] of Object.entries(value)) {
if (!/^[a-zA-Z][\w-]*$/.test(key) || entry == null) continue;
if (Array.isArray(entry)) {
if (!entry.length) continue;
lines.push(`${indent}${key}`);
for (const item of entry) {
if (!isRecord(item)) continue;
const firstKey = ["label", "id", "from"].find((candidate) => typeof item[candidate] === "string" && item[candidate]);
if (!firstKey) continue;
lines.push(`${indent} - ${firstKey} ${syntaxScalar(item[firstKey] as string)}`);
const rest = Object.fromEntries(Object.entries(item).filter(([field]) => field !== firstKey));
lines.push(...syntaxEntries(rest, `${indent} `));
}
} else if (isRecord(entry)) {
lines.push(`${indent}${key}`);
lines.push(...syntaxEntries(entry, `${indent} `));
} else if (["string", "number", "boolean"].includes(typeof entry)) {
lines.push(`${indent}${key} ${syntaxScalar(entry as string | number | boolean)}`);
}
}
return lines;
};
export const buildOfficialInfographicSyntax = (template: string, data: Record<string, unknown>, dark = false) => [
`infographic ${template}`,
...(dark ? ["theme dark"] : []),
"data",
...syntaxEntries(data, " "),
].join("\n");
const normalizedDatum = (value: unknown, depth = 0): Record<string, unknown> | null => {
if (!isRecord(value) || depth > 5) return null;
const result: Record<string, unknown> = {};
for (const field of ["id", "from", "to", "group", "label", "desc", "direction", "category"]) {
if (typeof value[field] === "string" && String(value[field]).trim()) result[field] = stringValue(value[field]);
}
if (!result.desc && typeof value.description === "string") result.desc = stringValue(value.description);
if (typeof value.value === "number" && Number.isFinite(value.value)) result.value = value.value;
if (Array.isArray(value.children)) result.children = value.children.slice(0, 12).map((child) => normalizedDatum(child, depth + 1)).filter(Boolean);
return Object.keys(result).length ? result : null;
};
export const parseGeneratedOfficialData = (output: string, template: string): Record<string, unknown> | null => {
const first = output.indexOf("{");
const last = output.lastIndexOf("}");
if (first < 0 || last <= first) return null;
let parsed: unknown;
try { parsed = JSON.parse(output.slice(first, last + 1)); } catch { return null; }
if (!isRecord(parsed)) return null;
const source = isRecord(parsed.data) ? parsed.data : parsed;
const family = officialTemplateFamily(template);
const field = { chart: "values", comparison: "compares", hierarchy: "root", list: "lists", quadrant: "compares", relation: "nodes", sequence: "sequences" }[family];
const data: Record<string, unknown> = { title: stringValue(source.title) || "信息图" };
if (typeof source.desc === "string" || typeof source.description === "string") data.desc = stringValue(source.desc ?? source.description);
if (family === "hierarchy") {
const root = normalizedDatum(source.root);
if (!root?.label) return null;
data.root = root;
} else {
const raw = source[field] ?? source.items;
if (!Array.isArray(raw)) return null;
const items = raw.slice(0, family === "quadrant" ? 4 : family === "comparison" ? 4 : 16).map((item) => normalizedDatum(item)).filter((item): item is Record<string, unknown> => Boolean(item));
if (!items.length || (family === "quadrant" && items.length !== 4) || (template.startsWith("compare-binary-") && items.length !== 2) || (template === "compare-swot" && items.length !== 4)) return null;
if (family !== "relation" && items.some((item) => !item.label)) return null;
if (family === "chart" && items.some((item) => typeof item.value !== "number" || !item.label)) return null;
if (family === "relation" && (items.some((item) => !item.id || !item.label) || new Set(items.map((item) => item.id)).size !== items.length)) return null;
data[field] = items;
if (family === "relation") {
const ids = new Set(items.map((item) => item.id));
const relations = Array.isArray(source.relations) ? source.relations.slice(0, 24).map((item) => normalizedDatum(item)).filter((item): item is Record<string, unknown> => Boolean(item?.from && item?.to && ids.has(item.from) && ids.has(item.to))) : [];
data.relations = relations;
}
}
return data;
};
export const parseGeneratedOfficialSelection = (output: string, templates: string[]) => {
const first = output.indexOf("{");
const last = output.lastIndexOf("}");
if (first < 0 || last <= first) return null;
let parsed: unknown;
try { parsed = JSON.parse(output.slice(first, last + 1)); } catch { return null; }
if (!isRecord(parsed) || typeof parsed.template !== "string" || !templates.includes(parsed.template)) return null;
const data = parseGeneratedOfficialData(output, parsed.template);
return data ? { template: parsed.template, data } : null;
};
export const shortlistOfficialTemplates = (request: string, templates: string[], currentTemplate?: string) => {
const requestedFamily: OfficialTemplateFamily = templateForRequest(request)?.startsWith("chart-") ? "chart"
: /(思维导图|脑图|树状图|组织结构|层级|mind\s*map|hierarchy|org chart)/i.test(request) ? "hierarchy"
: /(关系图|网络图|network|relation)/i.test(request) ? "relation"
: /(四象限|象限|quadrant)/i.test(request) ? "quadrant"
: /(对比|比较|差异|swot|\bvs\b|comparison)/i.test(request) ? "comparison"
: /(步骤|流程|时间线|时间轴|路线图|里程碑|step|process|timeline|roadmap)/i.test(request) ? "sequence"
: /(饼图|柱状图|折线图|图表|数据|chart|graph)/i.test(request) ? "chart" : "list";
const words = request.toLowerCase().match(/[a-z]+/g) ?? [];
const preferred = new Set([templateForRequest(request), currentTemplate].filter(Boolean));
const candidates = templates.filter((id) => officialTemplateFamily(id) === requestedFamily);
const comparisonCandidates = requestedFamily === "comparison"
? /(swot|优劣势)/i.test(request) ? candidates.filter((id) => id === "compare-swot")
: /(优缺点|利弊|pros|cons)/i.test(request) ? candidates.filter((id) => id.includes("pros-cons"))
: /(前后|变化|before|after|箭头)/i.test(request) ? candidates.filter((id) => id.includes("arrow"))
: candidates.filter((id) => id.endsWith("-vs"))
: candidates;
return (comparisonCandidates.length ? comparisonCandidates : candidates)
.map((id, index) => ({ id, score: (preferred.has(id) ? 100 : 0) + words.filter((word) => word.length > 2 && id.includes(word)).length * 8 - index * 0.01 }))
.sort((left, right) => right.score - left.score)
.slice(0, 12)
.map(({ id }) => id);
};
const plainLine = (value: string) => value.replace(/\s+/g, " ").trim();
export const buildInfographicSyntax = (input: {
template: string;
title: string;
description?: string;
items: InfographicItem[];
dark?: boolean;
}) => {
const lines = [`infographic ${input.template}`];
if (input.dark) lines.push("theme dark");
lines.push("data");
if (input.title.trim()) lines.push(` title ${plainLine(input.title)}`);
if (input.description?.trim()) lines.push(` desc ${plainLine(input.description)}`);
if (input.items.length) {
const comparison = input.template.startsWith("compare-binary-");
lines.push(input.template.startsWith("sequence-") ? " sequences" : comparison || /^(compare-)?quadrant-/.test(input.template) ? " compares" : " lists");
for (const item of input.items) {
lines.push(` - label ${plainLine(item.label)}`);
if (comparison) {
lines.push(" children", ` - label ${plainLine(item.label)}`);
if (item.description?.trim()) lines.push(` desc ${plainLine(item.description)}`);
for (const child of item.children ?? []) {
lines.push(` - label ${plainLine(child.label)}`);
if (child.description?.trim()) lines.push(` desc ${plainLine(child.description)}`);
}
} else if (item.description?.trim()) lines.push(` desc ${plainLine(item.description)}`);
}
}
return lines.join("\n");
};
export const inferInfographicKind = (request: string): InfographicKind | null => {
if (/(四象限|象限|swot|quadrant|2\s*[×xX]\s*2)/i.test(request)) return "quadrant";
if (/(对比|比较|差异|\bvs\.?\b|\bversus\b|compar(?:e|ison))/i.test(request)) return "comparison";
if (/(时间线|时间轴|路线图|里程碑|timeline|chronolog|roadmap)/i.test(request)) return "timeline";
if (/(步骤|流程|step|process)/i.test(request)) return "steps";
if (/(清单|列表|金字塔|网格|list|pyramid|grid)/i.test(request)) return "list";
return null;
};
const inferInfographicTemplate = (request: string, kind: InfographicKind): string | null => {
if (kind === "quadrant" && /(圆形|圆环|circular)/i.test(request)) return "compare-quadrant-quarter-circular";
if (kind === "comparison" && /(紧凑|密集|compact)/i.test(request)) return "compare-binary-horizontal-compact-card-vs";
if (kind === "comparison" && /(前后|变化|方向|箭头|arrow)/i.test(request)) return "compare-binary-horizontal-simple-arrow";
if (kind === "timeline" && /(路线图|roadmap)/i.test(request)) return "sequence-roadmap-vertical-simple";
if (kind === "timeline" && /(里程碑|milestone)/i.test(request)) return "sequence-timeline-done-list";
if (kind === "list" && /(金字塔|pyramid)/i.test(request)) return "list-pyramid-compact-card";
if (kind === "list" && /(网格|grid)/i.test(request)) return "list-grid-simple";
if (kind === "steps" && /(交错|zigzag)/i.test(request)) return "sequence-horizontal-zigzag-simple";
if (kind === "steps" && /(编号|数字|numbered)/i.test(request)) return "sequence-steps-simple";
return null;
};
export const shouldReplaceExistingInfographic = (request: string, currentKind: InfographicKind | undefined) => {
if (!currentKind) return true;
const requestedKind = inferInfographicKind(request);
if (requestedKind && requestedKind !== currentKind) return true;
if (/(模板|版式|风格|样式|布局|紧凑|圆形|template|layout|style)/i.test(request) && !/(生成|画|做|重做|重新)/i.test(request)) return false;
return /(生成|画|做|换成|改成|重做|重新).{0,40}(图|信息图|对比|比较|时间线|流程)/i.test(request);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value);
const stringValue = (value: unknown) => typeof value === "string" ? plainLine(value).slice(0, 160) : "";
const briefValue = (value: unknown, length: number) => Array.from(stringValue(value)).slice(0, length).join("");
export const parseGeneratedInfographicContent = (output: string, request: string): InfographicContent | null => {
const first = output.indexOf("{");
const last = output.lastIndexOf("}");
if (first < 0 || last <= first) return null;
let root: unknown;
try { root = JSON.parse(output.slice(first, last + 1)); } catch { return null; }
if (!isRecord(root)) return null;
const data = isRecord(root.data) ? root.data : root;
const rawKind = stringValue(data.kind ?? data.type).toLowerCase();
const kind = inferInfographicKind(request)
?? (INFOGRAPHIC_TEMPLATES.find((item) => item.kind === rawKind)?.kind ?? "steps");
const requestedTemplate = stringValue(data.template);
const template = inferInfographicTemplate(request, kind)
?? selectableInfographicTemplates.find((item) => item.id === requestedTemplate && item.kind === kind)?.id
?? selectableInfographicTemplates.find((item) => item.kind === kind)!.id;
const rawItems = data.items ?? data.quadrants ?? data.compares ?? data.steps ?? data.lists ?? data.sequences;
if (!Array.isArray(rawItems)) return null;
const items = rawItems.slice(0, kind === "quadrant" ? 4 : kind === "comparison" ? 2 : 12).map((value): InfographicItem | null => {
if (typeof value === "string") return { label: stringValue(value) };
if (!isRecord(value)) return null;
const label = kind === "comparison" ? briefValue(value.label ?? value.title ?? value.name, 16) : stringValue(value.label ?? value.title ?? value.name);
const description = kind === "comparison" ? briefValue(value.description ?? value.desc ?? value.detail, 24) : stringValue(value.description ?? value.desc ?? value.detail);
const children = kind === "comparison" && Array.isArray(value.children)
? value.children.slice(0, 3).map((child): InfographicItem | null => {
if (!isRecord(child)) return null;
const childLabel = briefValue(child.label ?? child.title ?? child.name, 12);
const childDescription = briefValue(child.description ?? child.desc ?? child.detail, 24);
return childLabel ? { label: childLabel, ...(childDescription ? { description: childDescription } : {}) } : null;
}).filter((child): child is InfographicItem => Boolean(child))
: [];
return label ? { label, ...(description ? { description } : {}), ...(children.length ? { children } : {}) } : null;
}).filter((value): value is InfographicItem => Boolean(value?.label));
if (!items.length || (kind === "quadrant" && items.length !== 4) || (kind === "comparison" && items.length !== 2)) return null;
return {
kind,
template,
title: stringValue(data.title) || (kind === "quadrant" ? "四象限图" : stringValue(request).slice(0, 60)),
description: stringValue(data.description ?? data.desc),
items,
};
};
export const generatedInfographicSyntax = (content: InfographicContent) =>
buildInfographicSyntax({
template: content.template,
title: content.title,
description: content.description,
items: content.items,
});
+2
View File
@@ -7,6 +7,7 @@ import {
markdownToDoc,
mergeMemoDocs,
getDiagramSummary,
getInfographicSummary,
getTableSummary,
getNotebookDescendantIds,
resolveMemoContentDoc,
@@ -78,6 +79,7 @@ const toSummary = (memo: MemoDetail): MemoSummary => ({
title: memo.title,
excerpt: memo.excerpt,
...getDiagramSummary(memo.contentMarkdown),
...getInfographicSummary(memo.contentMarkdown),
...getTableSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
+2
View File
@@ -3,6 +3,7 @@ import {
createExcerpt,
docToText,
getDiagramSummary,
getInfographicSummary,
getTableSummary,
resolveMemoContentDoc,
type MemoDetail,
@@ -24,6 +25,7 @@ export const memoToSummary = (memo: MemoDetail): MemoSummary => ({
title: memo.title,
excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))),
...getDiagramSummary(memo.contentMarkdown),
...getInfographicSummary(memo.contentMarkdown),
...getTableSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
+77 -2
View File
@@ -23,7 +23,7 @@
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-query": "latest",
"@tanstack/react-table": "8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/core": "3.31.0",
"@tiptap/extension-collaboration": "3.31.0",
@@ -103,7 +103,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.82.0",
"version": "1.86.0",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
@@ -220,6 +220,7 @@
"name": "@edgeever/web",
"version": "0.1.3",
"dependencies": {
"@antv/infographic": "0.2.20",
"@antv/x6": "3.1.8",
"@codemirror/lang-markdown": "^6.5.2",
"@edgeever/client": "workspace:*",
@@ -321,6 +322,20 @@
"@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="],
"@antv/event-emitter": ["@antv/event-emitter@0.1.3", "", {}, "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg=="],
"@antv/expr": ["@antv/expr@1.0.2", "", {}, "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg=="],
"@antv/graphlib": ["@antv/graphlib@2.0.4", "", { "dependencies": { "@antv/event-emitter": "^0.1.3" } }, "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w=="],
"@antv/hierarchy": ["@antv/hierarchy@0.7.1", "", {}, "sha512-7r22r+HxfcRZp79ZjGmsn97zgC1Iajrv0Mm9DIgx3lPfk+Kme2MG/+EKdZj1iEBsN0rJRzjWVPGL5YrBdVHchw=="],
"@antv/infographic": ["@antv/infographic@0.2.20", "", { "dependencies": { "@antv/hierarchy": "^0.7.0", "@antv/layout": "^2.0.0-beta.0", "culori": "^4.0.2", "d3": "^7.9.0", "eventemitter3": "^5.0.1", "flru": "^1.0.2", "linkedom": "^0.18.12", "lodash-es": "^4.17.21", "measury": "^0.1.5", "postcss": "^8.5.6", "roughjs": "^4.6.6", "round-polygon": "^0.6.7", "tinycolor2": "^1.6.0" } }, "sha512-XC1YXpQu6lJ3ZODUS7qBtai5cA8rUrzpegiOHjeCLElzJ6fki+N3yeosIKoYCUNcRCCUovgeSyMMIzzf2ndN2w=="],
"@antv/layout": ["@antv/layout@2.0.1", "", { "dependencies": { "@antv/event-emitter": "^0.1.3", "@antv/expr": "^1.0.2", "@antv/graphlib": "^2.0.0", "@antv/util": "^3.3.2", "comlink": "^4.4.1", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.5", "d3-octree": "^1.0.2", "d3-quadtree": "^3.0.1", "dagre": "^0.8.5", "ml-matrix": "^6.10.4", "tslib": "^2.8.1" } }, "sha512-8spOKfRVs4XAsUJI8cu9Gkpw4t2/0+VOBfeI+ehbqN+ENDlavnlzchkHNBERZyqNZlIfZOvTxDuAj1HSt/secQ=="],
"@antv/util": ["@antv/util@3.3.11", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "gl-matrix": "^3.3.0", "tslib": "^2.3.1" } }, "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg=="],
"@antv/x6": ["@antv/x6@3.1.8", "", { "dependencies": { "dom-align": "^1.12.4", "lodash-es": "^4.17.15", "mousetrap": "^1.6.5", "utility-types": "^3.10.0" } }, "sha512-JVGxBRmjnqDr7W7Ql5XWc6FUtFcy+QlFaV31cv0pHIeVIJmyYnB5rHmdzz93CY5lCS/XcNBtOnNIIq6JVgfZ5w=="],
"@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.7", "", { "dependencies": { "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw=="],
@@ -1977,6 +1992,8 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -2049,8 +2066,12 @@
"csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="],
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
@@ -2065,6 +2086,8 @@
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-binarytree": ["d3-binarytree@1.0.2", "", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="],
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
@@ -2087,6 +2110,8 @@
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-force-3d": ["d3-force-3d@3.0.6", "", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
@@ -2095,6 +2120,8 @@
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-octree": ["d3-octree@1.1.0", "", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
@@ -2123,6 +2150,8 @@
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="],
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
@@ -2503,6 +2532,8 @@
"flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
"flru": ["flru@1.0.2", "", {}, "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog=="],
"fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
"fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="],
@@ -2565,6 +2596,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@@ -2587,6 +2620,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="],
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
@@ -2711,6 +2746,8 @@
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-any-array": ["is-any-array@3.0.0", "", {}, "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
@@ -2931,6 +2968,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="],
"linkify-it": ["linkify-it@2.2.0", "", { "dependencies": { "uc.micro": "^1.0.1" } }, "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw=="],
"linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="],
@@ -3045,6 +3084,8 @@
"mdurl": ["mdurl@1.0.1", "", {}, "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="],
"measury": ["measury@0.1.5", "", {}, "sha512-YS9nhEbFECQzM9V7kyYylASFwpMyajeRvVnpmfRnhZZwbl9AfpBW+C/o7YwmIQLjK8XFzPHt0OGR7GwqoDuWjA=="],
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
@@ -3177,6 +3218,14 @@
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"ml-array-max": ["ml-array-max@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A=="],
"ml-array-min": ["ml-array-min@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0" } }, "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg=="],
"ml-array-rescale": ["ml-array-rescale@2.0.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-max": "^2.0.0", "ml-array-min": "^2.0.0" } }, "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg=="],
"ml-matrix": ["ml-matrix@6.15.0", "", { "dependencies": { "is-any-array": "^3.0.0", "ml-array-rescale": "^2.0.0" } }, "sha512-wFa1v6KP8bKp+fj0nYmRs1Pb5K4zRkXGKsOvLinvILENFIADncm4XlOI+S1M7yuACMGfI6cfk0IifDgd4j5xmw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="],
@@ -3659,6 +3708,8 @@
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
"round-polygon": ["round-polygon@0.6.7", "", {}, "sha512-h5rNwOEP6+EHeRuZAGI2/HsNdl6UCKIFvQXzQfv7nTICZ4/sIURe51vOmQJgk/I6KW5LjQ87HJFoASKjaIEySQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -3883,6 +3934,8 @@
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -3945,6 +3998,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
@@ -4623,6 +4678,8 @@
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"linkedom/css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
"log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"markdown-it/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -5193,6 +5250,16 @@
"jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"linkedom/css-select/boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
"linkedom/css-select/css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
"linkedom/css-select/domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
"linkedom/css-select/domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
"linkedom/css-select/nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -5503,6 +5570,12 @@
"expo-updates/glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"linkedom/css-select/domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"linkedom/css-select/domutils/dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
"linkedom/css-select/domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
"log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -5579,6 +5652,8 @@
"expo-updates/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"linkedom/css-select/domutils/dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
+16
View File
@@ -2,6 +2,22 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const enUS = {
infographic: {
name: "Infographic", autoLayout: "Auto layout", describe: "Describe and generate", chooseTemplate: "Choose a template and fill content",
prompt: "Describe the information, structure, and key points", generate: "Generate infographic", generating: "Generating…",
refine: "Describe what to change", refinePrompt: "For example: change the second item to user growth", applyRefinement: "Revise with AI", undoGeneration: "Undo last generation", fineTune: "Fine-tune manually",
doubleClickText: "Double-click text in the graphic to edit it. Changes save automatically.",
structureSettings: "Structure and style · Bulk edit", editSyntax: "Edit source syntax", backToStructure: "Back to structure and style",
title: "Title", description: "Description", items: "Items (one per line, use ‘Title | Description’)", comparisonItems: "One line per side; add aspects as ‘- Aspect | Description’", template: "Template", theme: "Color theme",
content: "Content", syntax: "Syntax", preview: "Preview", save: "Save", saving: "Saving…", exportSvg: "Export SVG", exportPng: "Export PNG",
empty: "Describe your content or start from a template.", noPreview: "Add content to see a preview.",
invalidSyntax: "The infographic syntax is invalid. Check it and try again.", renderError: "Rendering failed. Adjust the syntax or template.",
saveError: "Could not save. Try again.", sessionError: "Could not start an editing session. Try again.", aiError: "Generation failed. Check your AI model settings and try again.", aiInvalidResponse: "The generated content could not make an infographic. Try again.",
selectTemplate: "Choose template", simpleList: "Horizontal steps", numberedSteps: "Numbered steps", zigzagSteps: "Zigzag process", verticalList: "Vertical list", gridList: "Grid list", pyramidList: "Pyramid list", timeline: "Timeline", timelineChecklist: "Milestone timeline", roadmap: "Vertical roadmap", comparison: "Side-by-side comparison", compactComparison: "Compact comparison", arrowComparison: "Directional comparison", quadrant: "Quadrant", circularQuadrant: "Circular quadrant", themeDefault: "Default", themeDark: "Dark",
stepsGroup: "Process", listGroup: "List", timelineGroup: "Time and roadmap", comparisonGroup: "Comparison", quadrantGroup: "Quadrant",
officialTemplates: "AntV templates ({{count}})", searchTemplates: "Search template IDs", templateCategory: "Template category", allCategories: "All categories", chartCategory: "Chart", comparisonCategory: "Comparison", hierarchyCategory: "Hierarchy", listCategory: "List", quadrantCategory: "Quadrant", relationCategory: "Relation", sequenceCategory: "Sequence",
sourceHelp: "The syntax is the editable source of this infographic. The preview updates as you edit.", readOnly: "Read only",
},
companion: {
learning: { rule: { move: "You usually file notes tagged “{{tag}}” in “{{notebook}}”.", tag: "You often add “{{tag}}” to notes in “{{notebook}}”." }, "basedOn": "Memories used", "help": "Learning uses future filing and tag choices to improve suggestions. Pausing learning keeps memories. Disabling memory use stops recall in both chat and discoveries. Relevant memories are sent with selected notes to your default model.", "learningEnabled": "Learn from my choices", "useMemory": "Use saved memories", "active": "Inferred from repeated choices · Active", "candidate": "Candidate preference · Not used yet", "conflicted": "Insufficient or conflicting evidence · Paused", "evidence": "View supporting choices", "feedback": "Stop suggesting this kind of organization", "feedbackHelp": "Remembers your preference for these notebooks. You can correct or forget it in personal memory."},
discovery: {
+16
View File
@@ -2,6 +2,22 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const ja = {
infographic: {
name: "インフォグラフィック", autoLayout: "自動配置", describe: "説明して生成", chooseTemplate: "テンプレートを選んで入力",
prompt: "表示したい情報、構成、要点を説明してください", generate: "インフォグラフィックを生成", generating: "生成中…",
refine: "変更したい点を説明", refinePrompt: "例:2 番目の項目をユーザー成長に変更", applyRefinement: "AI で修正", undoGeneration: "前回の生成を元に戻す", fineTune: "手動で微調整",
doubleClickText: "図内の文字をダブルクリックして編集できます。変更は自動保存されます。",
structureSettings: "構成とスタイル · 一括編集", editSyntax: "元の構文を編集", backToStructure: "構成とスタイルに戻る",
title: "タイトル", description: "説明", items: "項目(1 行に 1 件、「タイトル | 説明」)", comparisonItems: "比較する 2 者を各 1 行にし、観点は「- 項目 | 説明」で追加", template: "テンプレート", theme: "配色",
content: "内容", syntax: "構文", preview: "プレビュー", save: "保存", saving: "保存中…", exportSvg: "SVG を出力", exportPng: "PNG を出力",
empty: "内容を説明するか、テンプレートから始めてください。", noPreview: "内容を入力するとプレビューが表示されます。",
invalidSyntax: "構文が正しくありません。確認して再試行してください。", renderError: "描画に失敗しました。構文またはテンプレートを調整してください。",
saveError: "保存できませんでした。再試行してください。", sessionError: "編集セッションを開始できませんでした。", aiError: "生成に失敗しました。AI モデルの設定を確認してください。", aiInvalidResponse: "生成内容からインフォグラフィックを作成できません。再試行してください。",
selectTemplate: "テンプレートを選択", simpleList: "横向きの手順", numberedSteps: "番号付き手順", zigzagSteps: "ジグザグ手順", verticalList: "縦向きのリスト", gridList: "グリッドリスト", pyramidList: "ピラミッドリスト", timeline: "タイムライン", timelineChecklist: "マイルストーン", roadmap: "縦型ロードマップ", comparison: "左右比較図", compactComparison: "コンパクト比較図", arrowComparison: "方向比較図", quadrant: "四象限図", circularQuadrant: "円形四象限", themeDefault: "標準", themeDark: "ダーク",
stepsGroup: "手順", listGroup: "リスト", timelineGroup: "時間とロードマップ", comparisonGroup: "比較", quadrantGroup: "四象限",
officialTemplates: "AntV テンプレート({{count}})", searchTemplates: "テンプレート ID を検索", templateCategory: "テンプレート分類", allCategories: "すべての分類", chartCategory: "チャート", comparisonCategory: "比較", hierarchyCategory: "階層", listCategory: "リスト", quadrantCategory: "四象限", relationCategory: "関係", sequenceCategory: "順序",
sourceHelp: "構文が編集可能な元データです。変更するとプレビューが更新されます。", readOnly: "読み取り専用",
},
companion: {
learning: { rule: { move: "「{{tag}}」タグのノートは、たいてい「{{notebook}}」に整理しています。", tag: "「{{notebook}}」のノートには、よく「{{tag}}」を付けています。" }, "basedOn": "参照した記憶", "help": "学習をオンにすると、今後の整理やタグ付けから好みを蓄積し、提案の精度を高めます。学習を一時停止しても記憶は残ります。記憶の使用を止めると、チャットと発見のどちらでも参照されません。関連する記憶は、選んだノートとともにデフォルトモデルへ送信されます。", "learningEnabled": "操作から学習する", "useMemory": "保存した記憶を使う", "active": "繰り返しの操作から推測 · 有効", "candidate": "候補の好み · まだ未使用", "conflicted": "根拠不足または矛盾 · 一時停止", "evidence": "根拠となった操作を見る", "feedback": "この整理の提案をやめる", "feedbackHelp": "これらのノートブックに対する好みを記憶します。個人記憶で訂正または削除できます。"},
discovery: {
+16
View File
@@ -2,6 +2,22 @@ import { memoTemplateSeedTranslations } from "../memo-template-seeds";
// Shared by the PWA and native mobile clients.
export const zhCN = {
infographic: {
name: "信息图", autoLayout: "自动排版", describe: "描述内容并生成", chooseTemplate: "选择模板手动填内容",
prompt: "描述你想呈现的信息、结构与重点", generate: "生成信息图", generating: "正在生成…",
refine: "描述要修改的地方", refinePrompt: "例如:把第二项改成用户增长,其余内容保留", applyRefinement: "用 AI 修改", undoGeneration: "撤回上次生成", fineTune: "人工微调",
doubleClickText: "双击图中文字可直接修改,修改后自动保存。",
structureSettings: "结构与样式 · 批量调整", editSyntax: "编辑源语法", backToStructure: "返回结构与样式",
title: "标题", description: "说明", items: "条目(每行一个,使用“标题 | 说明”)", comparisonItems: "对比双方各占一行;子项用“- 维度 | 说明”", template: "模板", theme: "配色",
content: "内容", syntax: "语法", preview: "预览", save: "保存", saving: "保存中…", exportSvg: "导出 SVG", exportPng: "导出 PNG",
empty: "用一句话描述内容,或从模板开始。", noPreview: "填写内容后显示预览。",
invalidSyntax: "信息图语法无效,请检查后重试。", renderError: "信息图渲染失败,请调整语法或模板。",
saveError: "保存失败,请重试。", sessionError: "无法开启编辑会话,请重试。", aiError: "生成失败,请检查 AI 模型设置后重试。", aiInvalidResponse: "生成内容无法组成信息图,请重试。",
selectTemplate: "选择模板", simpleList: "横向步骤", numberedSteps: "编号步骤", zigzagSteps: "交错流程", verticalList: "纵向清单", gridList: "网格清单", pyramidList: "金字塔清单", timeline: "时间线", timelineChecklist: "里程碑时间线", roadmap: "纵向路线图", comparison: "双栏对比图", compactComparison: "紧凑对比图", arrowComparison: "方向对比图", quadrant: "四象限图", circularQuadrant: "圆形四象限", themeDefault: "默认", themeDark: "深色",
stepsGroup: "流程型", listGroup: "列表型", timelineGroup: "时间与路线", comparisonGroup: "对比型", quadrantGroup: "四象限型",
officialTemplates: "AntV 官方模板({{count}})", searchTemplates: "搜索模板 ID", templateCategory: "模板分类", allCategories: "全部分类", chartCategory: "图表型", comparisonCategory: "对比型", hierarchyCategory: "层级型", listCategory: "列表型", quadrantCategory: "四象限型", relationCategory: "关系型", sequenceCategory: "顺序型",
sourceHelp: "语法是此信息图的可编辑源文件;预览随更改更新。", readOnly: "只读",
},
companion: {
learning: { rule: { move: "你通常把带「{{tag}}」标签的笔记归入「{{notebook}}」。", tag: "你经常给「{{notebook}}」中的笔记添加「{{tag}}」标签。" }, "basedOn": "参考了这些记忆", "help": "开启学习后,猫爪会从之后的归档与标签选择中积累偏好,用于更相关的建议。暂停学习会保留记忆;停止使用后,对话和主动发现都不再参考记忆。相关记忆会随选中的笔记发送给默认模型。", "learningEnabled": "从操作中学习", "useMemory": "使用已保存的记忆", "active": "根据多次操作推测 · 已生效", "candidate": "候选偏好 · 尚未用于建议", "conflicted": "证据不足或存在冲突 · 暂停使用", "evidence": "查看操作依据", "feedback": "不再建议这种整理", "feedbackHelp": "会记住你对这些笔记本的整理偏好,可在个人记忆中纠正或忘记。"},
discovery: {
+1
View File
@@ -34,6 +34,7 @@ export * from "./desktop-rpc";
export * from "./details";
export * from "./deployment-metadata";
export * from "./diagram";
export * from "./infographic";
export * from "./table";
export * from "./diagram-palette";
export * from "./diagram-flowchart-style";
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import {
createDefaultInfographicDocument,
getInfographicSummary,
parseInfographicDocument,
serializeInfographicDocument,
} from "./infographic.ts";
describe("infographic note format", () => {
test("round trips AntV syntax and keeps a readable fallback", () => {
const document = {
schemaVersion: 1,
syntax: "infographic list-row-simple-horizontal-arrow\ndata\n title 发布流程\n lists\n - label 准备\n - label 发布",
};
const markdown = serializeInfographicDocument(document);
expect(markdown).toContain("```infographic\n");
expect(parseInfographicDocument(markdown)).toEqual(document);
expect(getInfographicSummary(markdown)).toEqual({ infographic: true });
});
test("empty and invalid notes do not claim a valid document", () => {
expect(parseInfographicDocument(serializeInfographicDocument(createDefaultInfographicDocument()))).toEqual(createDefaultInfographicDocument());
expect(parseInfographicDocument("<!-- edgeever-infographic-v1:broken -->")).toBeNull();
expect(getInfographicSummary("regular note")).toEqual({ infographic: false });
});
});
+48
View File
@@ -0,0 +1,48 @@
import { Base64 } from "js-base64";
export const INFOGRAPHIC_SCHEMA_VERSION = 1 as const;
const MARKER = "edgeever-infographic-v1";
const COMMENT = /<!--\s*edgeever-infographic-v1:([A-Za-z0-9_-]+)\s*-->/;
export type InfographicDocument = {
schemaVersion: typeof INFOGRAPHIC_SCHEMA_VERSION;
syntax: string;
};
export const createDefaultInfographicDocument = (): InfographicDocument => ({
schemaVersion: INFOGRAPHIC_SCHEMA_VERSION,
syntax: "",
});
export const infographicFallbackMarkdown = (document: InfographicDocument) =>
document.syntax.trim() ? `\`\`\`infographic\n${document.syntax.trim()}\n\`\`\`` : "";
export const serializeInfographicDocument = (document: InfographicDocument) => {
const fallback = infographicFallbackMarkdown(document);
const marker = `<!-- ${MARKER}:${Base64.encodeURI(JSON.stringify(document))} -->`;
return fallback ? `${fallback}\n\n${marker}` : marker;
};
export const hasInfographicDocumentMarker = (markdown: string | null | undefined) =>
Boolean(markdown?.includes(`<!-- ${MARKER}:`));
export const stripInfographicDocumentMarker = (markdown: string) =>
markdown.replace(/<!--\s*edgeever-infographic-v1:[\s\S]*?-->/g, "").trim();
export const parseInfographicDocument = (markdown: string | null | undefined): InfographicDocument | null => {
const encoded = markdown?.match(COMMENT)?.[1];
if (!encoded || encoded.length > 2_000_000) return null;
try {
const value: unknown = JSON.parse(Base64.decode(encoded));
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const document = value as Record<string, unknown>;
if (document.schemaVersion !== INFOGRAPHIC_SCHEMA_VERSION || typeof document.syntax !== "string" || document.syntax.length > 200_000) return null;
return { schemaVersion: INFOGRAPHIC_SCHEMA_VERSION, syntax: document.syntax };
} catch {
return null;
}
};
export const getInfographicSummary = (markdown: string | null | undefined) => ({
infographic: Boolean(parseInfographicDocument(markdown)),
});
+2 -1
View File
@@ -3,7 +3,7 @@ import type { AiAction, AiPromptParameterKind, AiPromptResultMode } from "./ai-a
import type { DiagramKind, DiagramSummaryPreview } from "./diagram";
import type { TableSummaryPreview } from "./table";
export type NoteCreateKind = DiagramKind | "table";
export type NoteCreateKind = DiagramKind | "infographic" | "table";
export type Notebook = {
id: string;
@@ -28,6 +28,7 @@ export type MemoSummary = {
diagramPreview?: DiagramSummaryPreview;
structuredTable?: boolean;
tablePreview?: TableSummaryPreview;
infographic?: boolean;
tags: string[];
isPinned: boolean;
isArchived: boolean;