feat: add native poster notes and unified AI editing
This commit is contained in:
1 parent
068235614b
commit
0da78f6ce8
109 files changed
+8723
-44
No files matched your search
+2
-1
@@ -180,7 +180,7 @@ curl -fsSL https://edgeever.org/install.sh | bash
|
||||
|
||||
### Telegram コミュニティ
|
||||
|
||||
EdgeEver の使い方、AI Agent の実例、費用対効果の高い / 無料の AI 資源、自動化の流れを話す場です。
|
||||
EdgeEver コミュニティへようこそ。Vibe Coding や AI を探求する仲間が集まり、最先端の AI 生産性と個人ナレッジベースのベストプラクティスを共有しています。
|
||||
|
||||
👉 [EdgeEver の Telegram グループに参加](https://t.me/+wwUx1BYLrIdiZjY1)
|
||||
|
||||
@@ -287,6 +287,7 @@ EdgeEver は無料のオープンソースプロジェクトです。クロス
|
||||
|
||||
- ノート製品の設計は、[Evernote](https://evernote.com/) や [Notion](https://www.notion.com/) など成熟したノートツールの公開されている製品体験も参考にしています。関連機能は EdgeEver が独自に設計し、実装しています。
|
||||
- マインドマップと視覚的な図のノートは、[XMind](https://xmind.com/) と [ProcessOn](https://www.processon.com/) の公開されている製品体験を参考にしています。関連機能は EdgeEver が独自に設計し、実装しています。
|
||||
- EdgeEver のポスターデザイン機能は、向阳乔木(joeseesun)の [Qiaomu Design](https://github.com/joeseesun/qiaomu-cover-design) を参考にし、MIT ライセンスに基づいてテンプレートと文字組みエンジンを改変して利用しています。
|
||||
|
||||
## 商標とブランド
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ The instance administrator can create, disable, or reset member accounts in **Pr
|
||||
|
||||
### Telegram Community
|
||||
|
||||
Welcome to the EdgeEver community. Join us to discuss the EdgeEver experience, real-world AI Agent applications, cost-effective or free AI resources, and automation workflows.
|
||||
Welcome to the EdgeEver community, bringing together developers and creators exploring vibe coding and AI. Join us to discuss cutting-edge AI productivity and best practices for personal knowledge bases.
|
||||
|
||||
👉 [Join the EdgeEver Telegram group](https://t.me/+wwUx1BYLrIdiZjY1)
|
||||
|
||||
@@ -288,6 +288,7 @@ EdgeEver is a free and open-source project. Sustaining cross-platform client dev
|
||||
|
||||
- EdgeEver's note-taking product design was also informed by the publicly available product experiences of mature note-taking tools such as [Evernote](https://evernote.com/) and [Notion](https://www.notion.com/). The related features were independently designed and implemented by EdgeEver.
|
||||
- The product design of mind-map and visual-diagram notes was informed by the publicly available product experiences of [XMind](https://xmind.com/) and [ProcessOn](https://www.processon.com/). These features were independently designed and implemented by EdgeEver.
|
||||
- EdgeEver's poster design workspace draws inspiration from [Qiaomu Design](https://github.com/joeseesun/qiaomu-cover-design) by 向阳乔木 (joeseesun), and adapts its templates and typography engine under the MIT license.
|
||||
|
||||
## Trademark and Brand Use
|
||||
|
||||
|
||||
+2
-1
@@ -177,7 +177,7 @@ Jedna instancja obsługuje wiele kont. Administrator może tworzyć i wyłącza
|
||||
|
||||
### Społeczność na Telegramie
|
||||
|
||||
Zapraszamy do rozmów o korzystaniu z EdgeEver, agentach AI, niedrogich lub darmowych usługach AI i automatyzacji.
|
||||
Zapraszamy do społeczności EdgeEver, skupiającej twórców i pasjonatów vibe codingu oraz AI. Rozmawiajmy o nowoczesnej produktywności opartej na AI i sprawdzonych praktykach budowania osobistej bazy wiedzy.
|
||||
|
||||
👉 [Dołącz do grupy na Telegramie](https://t.me/+wwUx1BYLrIdiZjY1)
|
||||
|
||||
@@ -284,6 +284,7 @@ EdgeEver jest darmowym projektem o otwartym kodzie. Rozwijanie aplikacji na ró
|
||||
|
||||
- Przy projektowaniu EdgeEver wzorowano się na publicznie dostępnych rozwiązaniach dojrzałych aplikacji do notatek, takich jak [Evernote](https://evernote.com/) i [Notion](https://www.notion.com/). Powiązane funkcje zostały zaprojektowane i zaimplementowane niezależnie przez EdgeEver.
|
||||
- Inspiracją dla notatek z mapami myśli i diagramami były publicznie dostępne funkcje [XMind](https://xmind.com/) i [ProcessOn](https://www.processon.com/). Funkcje te zostały zaprojektowane i zaimplementowane niezależnie przez EdgeEver.
|
||||
- Narzędzie do projektowania plakatów w EdgeEver czerpie inspirację z [Qiaomu Design](https://github.com/joeseesun/qiaomu-cover-design) autorstwa 向阳乔木 (joeseesun) i wykorzystuje dostosowane szablony oraz silnik typograficzny udostępnione na licencji MIT.
|
||||
|
||||
## Znak towarowy i użycie marki
|
||||
|
||||
|
||||
+2
-1
@@ -181,7 +181,7 @@ curl -fsSL https://edgeever.org/install.sh | bash
|
||||
|
||||
<h3 id="wechat-group">微信交流群</h3>
|
||||
|
||||
欢迎加入 EdgeEver AI 交流群,这里聚集了大量 Vibe Coding 与 AI 玩家。一起交流 EdgeEver 体验、AI Agent 实战落地、高性价比/免费 AI 资源及自动化工作流。
|
||||
欢迎加入 EdgeEver AI 交流群,这里聚集了大量 Vibe Coding 与 AI 玩家。一起交流前沿 AI 生产力与个人知识库的最佳实践。
|
||||
|
||||
> 扫描下方二维码或添加微信 `m1245207870`(备注“EdgeEver 进群”),群主将手动邀请入群。
|
||||
|
||||
@@ -291,6 +291,7 @@ EdgeEver 是免费开源项目。保持跨平台客户端(macOS、Windows、Li
|
||||
|
||||
- EdgeEver 的笔记产品设计也参考了 [Evernote(印象笔记)](https://evernote.com/)、[Notion](https://www.notion.com/) 等成熟笔记工具的公开产品体验。相关功能由 EdgeEver 独立设计与实现。
|
||||
- 思维导图与可视化图表笔记的产品设计参考了 [XMind](https://xmind.com/) 和 [ProcessOn](https://www.processon.com/) 等图表工具的公开产品体验。相关功能由 EdgeEver 独立设计与实现。
|
||||
- EdgeEver 的海报设计工作台借鉴了向阳乔木(joeseesun)的 [Qiaomu Design](https://github.com/joeseesun/qiaomu-cover-design),并基于其 MIT 开源许可改编了模板与字体排版引擎。
|
||||
|
||||
## 商标与品牌使用
|
||||
|
||||
|
||||
+2
-1
@@ -179,7 +179,7 @@ curl -fsSL https://edgeever.org/install.sh | bash
|
||||
|
||||
<h3 id="wechat-group">微信交流群</h3>
|
||||
|
||||
歡迎加入 EdgeEver AI 交流群,這裡聚集了大量 Vibe Coding 與 AI 玩家。一起交流 EdgeEver 體驗、AI Agent 實戰落地、高性價比/免費 AI 資源及自動化工作流程。
|
||||
歡迎加入 EdgeEver AI 交流群,這裡聚集了大量 Vibe Coding 與 AI 玩家。一起交流前沿 AI 生產力與個人知識庫的最佳實踐。
|
||||
|
||||
> 掃描下方 QR Code 或加入微信 `m1245207870`(備註「EdgeEver 進群」),群主將手動邀請入群。
|
||||
|
||||
@@ -289,6 +289,7 @@ EdgeEver 是免費開源專案。保持跨平台用戶端(macOS、Windows、Li
|
||||
|
||||
- EdgeEver 的筆記產品設計也參考了 [Evernote(印象筆記)](https://evernote.com/)、[Notion](https://www.notion.com/) 等成熟筆記工具的公開產品體驗。相關功能由 EdgeEver 獨立設計與實作。
|
||||
- 心智圖與視覺化圖表筆記的產品設計參考了 [XMind](https://xmind.com/) 和 [ProcessOn](https://www.processon.com/) 等圖表工具的公開產品體驗。相關功能由 EdgeEver 獨立設計與實作。
|
||||
- EdgeEver 的海報設計工作台借鑑了向阳乔木(joeseesun)的 [Qiaomu Design](https://github.com/joeseesun/qiaomu-cover-design),並基於其 MIT 開源授權改編了範本與字型排版引擎。
|
||||
|
||||
## 商標與品牌使用
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ const TOOL_HINTS: Record<string, string> = {
|
||||
add_table_record: " Add one requested record to a structured table. First call get_table_records, then use its field IDs and latest revision. Do not invent factual records to fill a new table.",
|
||||
create_diagram_memo: " Create an editable visual diagram note. kind=mind-map for 思维导图/mind map, flowchart for 流程图, architecture for 架构图. Never use this for 信息图/infographic; call create_infographic_memo. Omit edge ids; EdgeEver generates them. For mind maps, give a root and children with parentId; omit node type. If the user did not name a notebook, omit notebookId. The diagram is saved in the inbox (等待分类). Do not ask which notebook, and do not use the open notebook unless the user named it. Build nodes from the open note body in Focus DATA when the user refers to this note.",
|
||||
create_infographic_memo: " Create an AntV infographic note (信息图). A share, proportion, or 占比 uses template chart-pie-donut-plain-text and numeric data.values, not a mind map. If the user did not supply the figures, say in data.desc that they are illustrative and are not an official disclosure. If the user did not name a notebook, omit notebookId. The infographic is saved in the inbox (等待分类). Do not ask which notebook, and do not use the open notebook unless the user named it.",
|
||||
get_poster: " Read the existing poster canvas and elements before editing. Do not use raw Markdown for posters.",
|
||||
update_poster: " Edit poster text, colors, typography and positions after get_poster. Preserve unmentioned and locked elements. Pass the revision you read.",
|
||||
get_diagram: " Read an existing editable diagram as a semantic graph. Call this before update_diagram. Do not use get_memo when you only need the diagram structure.",
|
||||
update_diagram: " Edit an existing diagram after get_diagram. Pass expectedRevision from get_diagram. Use add_node, update_node, remove_node, add_edge, update_edge, or remove_edge. Do not create a new diagram unless the user asked for a new note.",
|
||||
use_note_template: " Create a new memo from a template. If the user did not name a notebook, omit notebookId. The note is saved in the inbox (等待分类). Do not ask which notebook.",
|
||||
@@ -258,6 +260,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
if (!args.input.allowNotes || !args.context) return {};
|
||||
const session = args.session ?? emptyCompanionAgentSession();
|
||||
const inspected = new Map(Object.entries(session.inspected));
|
||||
const posterReads = new Map<string, number>();
|
||||
const persistSession = () => {
|
||||
session.inspected = Object.fromEntries(inspected);
|
||||
};
|
||||
@@ -356,15 +359,15 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
}
|
||||
parameters_.expectedRevision = memo.revision;
|
||||
}
|
||||
if (autoApply && parameters_.dryRun !== true && (definition.name === "update_memo" || definition.name === "update_diagram" || definition.name === "restore_memo_revision")) {
|
||||
if (autoApply && parameters_.dryRun !== true && (definition.name === "update_memo" || definition.name === "update_diagram" || definition.name === "update_poster" || definition.name === "restore_memo_revision")) {
|
||||
const memo = await getMemoDetail(args.db, args.scope.workspaceId, String(parameters_.memoId));
|
||||
if (!memo || inspected.get(memo.id) !== memo.revision) {
|
||||
if (!memo || inspected.get(memo.id) !== memo.revision || (definition.name === "update_poster" && posterReads.get(memo.id) !== memo.revision)) {
|
||||
throw new AppError("companion_action_unread",
|
||||
definition.name === "update_diagram"
|
||||
? "Call get_diagram on this note before changing it."
|
||||
: "Read the complete source notes before changing their content.", 400);
|
||||
}
|
||||
if (definition.name === "update_diagram") parameters_.expectedRevision = memo.revision;
|
||||
if (definition.name === "update_diagram" || definition.name === "update_poster") parameters_.expectedRevision = memo.revision;
|
||||
}
|
||||
if (autoApply && parameters_.dryRun !== true && definition.name === "merge_memos") {
|
||||
const memoIds = Array.isArray(parameters_.memoIds) ? parameters_.memoIds.map(String) : [];
|
||||
@@ -375,7 +378,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((definition.name === "get_memo" || definition.name === "get_diagram") && inspected.has(String(parameters_.memoId))) {
|
||||
if ((definition.name === "get_memo" || definition.name === "get_diagram" || (definition.name === "get_poster" && posterReads.get(String(parameters_.memoId)) === inspected.get(String(parameters_.memoId)))) && inspected.has(String(parameters_.memoId))) {
|
||||
// The original full result remains in this run's model messages. Only
|
||||
// reuse it after authorization/context/cursor checks, never across runs.
|
||||
return done({ id: parameters_.memoId, revision: inspected.get(String(parameters_.memoId)), alreadyRead: true,
|
||||
@@ -447,7 +450,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
changes: updated.changes,
|
||||
});
|
||||
}
|
||||
if (definition.name === "update_memo") {
|
||||
if (definition.name === "update_memo" || definition.name === "update_poster") {
|
||||
const updated = (result as { memo: MemoDetail }).memo;
|
||||
const receipt = await done({ applied: true, id: updated.id, title: updated.title,
|
||||
notebookId: updated.notebookId, revision: updated.revision });
|
||||
@@ -463,15 +466,16 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani
|
||||
return done({ applied: true, ...(typeof result === "object" && result ? result as object : { result }) });
|
||||
}
|
||||
if (current !== await companionWorkspaceCursor(args.db, args.scope.workspaceId)) return done({ error: "Notes changed during this read. Start a fresh request." });
|
||||
if (definition.name === "get_diagram") {
|
||||
const payload = result as { memo: { id: string; title: string | null; revision: number }; diagram: { kind?: string; nodes?: unknown[] } };
|
||||
if (definition.name === "get_diagram" || definition.name === "get_poster") {
|
||||
const payload = result as { memo: { id: string; title: string | null; revision: number; notebookId?: string }; diagram: { kind?: string; nodes?: unknown[] } };
|
||||
inspected.set(payload.memo.id, payload.memo.revision);
|
||||
if (definition.name === "get_poster") posterReads.set(payload.memo.id, payload.memo.revision);
|
||||
const known = args.sources.find(source => source.id === payload.memo.id);
|
||||
remember({
|
||||
id: payload.memo.id,
|
||||
title: payload.memo.title,
|
||||
revision: payload.memo.revision,
|
||||
notebookId: known?.notebookId || "",
|
||||
notebookId: known?.notebookId || payload.memo.notebookId || "",
|
||||
});
|
||||
return done(payload);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ When the user refers to this note, 这篇, 当前, or the open note, use the Ope
|
||||
When the user asks for a mind map, 思维导图, flowchart, 流程图, architecture diagram, or 架构图 of the open note, build it from that body (or get_memo/get_diagram first). To create a new diagram note, call create_diagram_memo. Do not use create_memo, Markdown outlines, or Mermaid. Preserve an explicitly requested type exactly: 思维导图/mind map means kind=mind-map, 流程图/flowchart means kind=flowchart, and 架构图/architecture diagram means kind=architecture. For mind maps, supply a root node and children with parentId; omit node type. If the user did not name a notebook, omit notebookId so it is saved in the inbox notebook (等待分类). Do not ask which notebook, and do not use the open notebook unless the user named it. Reply with the new note link.
|
||||
When the user asks for an infographic, 信息图, 資訊圖, or インフォグラフィック, call create_infographic_memo. Do not use create_diagram_memo, create_memo, Markdown, or Mermaid for that request. A share, proportion, or 占比 is a pie infographic: template chart-pie-donut-plain-text and numeric data.values, not a mind map. If the user did not supply the figures, say in data.desc that they are illustrative and are not an official disclosure. If the user did not name a notebook, omit notebookId. Reply with the new note link.
|
||||
When the user asks for a 多维表格, 多維表格, or structured table, call create_table_memo with explicit fields. Do not use create_memo or a Markdown table. If no records were supplied or requested, leave it empty. If records were requested, call get_table_records for the new table's field IDs and revision before add_table_record. Omit notebookId when no notebook was named. Reply with the new note link.
|
||||
To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo.
|
||||
To change an existing diagram, call get_diagram then update_diagram. Do not create a second diagram unless the user asked for a new note. Do not use update_memo or Mermaid for diagrams.
|
||||
A structured table cannot be replaced with update_memo. Use get_table_records to read its structure and records. Do not rewrite its Markdown.
|
||||
You can list, create, update, use, and delete note templates, and list, create, update, and delete AI instructions. use_note_template creates a memo immediately. You cannot empty the trash, share notes, or upload files. Do not claim those tools exist.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Hono } from "hono";
|
||||
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
|
||||
import { registerCompanionRoutes } from "./companion-routes.ts";
|
||||
import { beginCompanionTurn, checkpointCompanionTurn, clearCompanionHistory, saveCompanionMemory } from "./companion-service.ts";
|
||||
import { createDefaultTableDocument, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeTableDocument } from "@edgeever/shared";
|
||||
import { createPosterDocument, serializePosterDocument, parsePosterDocument, createDefaultTableDocument, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeTableDocument } from "@edgeever/shared";
|
||||
import { createMemoRecord, getMemoDetail, normalizeSearchTimeBound, updateMemoRecord } from "./memo-service.ts";
|
||||
import { companionWorkspaceCursor, proposeCompanionToolAction } from "./companion-tool-actions.ts";
|
||||
import { getCompanionAction, applyCompanionAction, dismissCompanionAction } from "./companion-actions.ts";
|
||||
@@ -49,7 +49,7 @@ async function setup() {
|
||||
|
||||
describe("shared companion MCP adapter", () => {
|
||||
test("reuses the exact reviewed MCP definitions, with no administration or upload tools", () => {
|
||||
expect(COMPANION_MCP_TOOLS).toHaveLength(47);
|
||||
expect(COMPANION_MCP_TOOLS).toHaveLength(49);
|
||||
for (const definition of COMPANION_MCP_TOOLS) expect(MCP_TOOLS.includes(definition)).toBe(true);
|
||||
for (const name of ["upload_memo_image", "upload_memo_attachment", "empty_trash", "share_memo"]) {
|
||||
expect(() => validateCompanionTool(name, {})).toThrow();
|
||||
@@ -115,13 +115,26 @@ describe("shared companion MCP adapter", () => {
|
||||
test("read and dry-run tools reuse MCP without writing or requiring a proposal", async () => {
|
||||
const f = await setup();
|
||||
const tools = createCompanionTools({ ...f, scope, signal: new AbortController().signal, assertActive: async () => {}, sources: [] });
|
||||
expect(Object.keys(tools)).toHaveLength(49);
|
||||
expect(Object.keys(tools)).toHaveLength(51);
|
||||
expect(await tools.get_memo.execute({ memoId: f.notes[0].id })).toMatchObject({ content: "One original content" });
|
||||
expect(await tools.trash_memos.execute({ memoIds: [f.notes[0].id], dryRun: true })).toMatchObject({ dryRun: true });
|
||||
expect(await getMemoDetail(f.db, scope.workspaceId, f.notes[0].id)).not.toBeNull();
|
||||
expect(f.sqlite.query("SELECT COUNT(*) AS n FROM companion_actions").get().n).toBe(0);
|
||||
expect(createCompanionTools({ ...f, scope, input: { ...f.input, allowNotes: false } })).toEqual({});
|
||||
});
|
||||
test("poster changes use the shared agent execution path with read and revision guards", async () => {
|
||||
const f = await setup();
|
||||
const created = await createMemoRecord(f.db, scope.workspaceId, { notebookId: "ideas", title: "Poster", contentMarkdown: serializePosterDocument(createPosterDocument("Hello")), tags: [] }, actor, "owner");
|
||||
const tools = createCompanionTools({ ...f, scope, signal: new AbortController().signal, assertActive: async () => {}, sources: [] });
|
||||
await expect(tools.update_poster.execute({ memoId: created.id, expectedRevision: created.revision, background: "#16A06E" })).rejects.toMatchObject({ code: "companion_action_unread" });
|
||||
await tools.get_memo.execute({ memoId: created.id });
|
||||
const read = await tools.get_poster.execute({ memoId: created.id });
|
||||
expect(read.poster.elements.length).toBeGreaterThan(0);
|
||||
const updated = await tools.update_poster.execute({ memoId: created.id, expectedRevision: read.memo.revision, background: "#16A06E" });
|
||||
expect(updated).toMatchObject({ applied: true, id: created.id });
|
||||
expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).background).toBe("#16A06E");
|
||||
expect(f.sqlite.query("SELECT COUNT(*) AS n FROM companion_actions").get().n).toBe(0);
|
||||
});
|
||||
test("read-only note access keeps MCP reads and omits write tools", async () => {
|
||||
const f = await setup();
|
||||
const tools = createCompanionTools({
|
||||
@@ -134,6 +147,8 @@ describe("shared companion MCP adapter", () => {
|
||||
expect(tools.create_diagram_memo).toBeUndefined();
|
||||
expect(tools.update_diagram).toBeUndefined();
|
||||
expect(tools.get_diagram).toBeTruthy();
|
||||
expect(tools.get_poster).toBeTruthy();
|
||||
expect(tools.update_poster).toBeUndefined();
|
||||
expect(tools.update_memo).toBeUndefined();
|
||||
expect(tools.merge_memos).toBeUndefined();
|
||||
expect(tools.list_note_templates).toBeTruthy();
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function proposeCompanionToolAction(db: DatabaseAdapter, scope: Com
|
||||
for (const id of new Set([...ids, ...evidenceIds])) {
|
||||
const memo = await getMemoDetail(db, scope.workspaceId, id, toolName === "restore_memos");
|
||||
if (!memo) throw stale();
|
||||
if ((toolName === "update_memo" || toolName === "merge_memos") && inspected.get(id) !== memo.revision) {
|
||||
if ((toolName === "update_memo" || toolName === "update_poster" || toolName === "merge_memos") && inspected.get(id) !== memo.revision) {
|
||||
throw new AppError("companion_action_unread", "Read the complete source notes before changing their content or merging them.", 400);
|
||||
}
|
||||
notes.push({ id, title: memo.title ?? "", revision: memo.revision, notebookId: memo.notebookId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppError } from "./app-error";
|
||||
// capabilities must be reviewed rather than silently acquiring user authority.
|
||||
const allowed = new Set([
|
||||
"get_current_user", "search_memos", "list_memos", "get_memo", "create_memo", "create_table_memo", "get_table_records", "add_table_record", "create_diagram_memo", "create_infographic_memo", "get_diagram",
|
||||
"update_diagram",
|
||||
"update_diagram", "get_poster", "update_poster",
|
||||
"import_memos", "update_memo",
|
||||
"trash_memos", "restore_memos", "move_memos", "add_tags_to_memos", "remove_tags_from_memos",
|
||||
"rename_tag", "delete_tag", "merge_memos", "list_memo_resources", "list_resources",
|
||||
|
||||
@@ -33,13 +33,13 @@ export function describeCompanionTool(
|
||||
const memos = Array.isArray(record?.memos) ? record.memos : [];
|
||||
return memos.slice(0, 5).map(item => effect("listed", asMemo(item)));
|
||||
}
|
||||
if (name === "get_memo" || name === "get_diagram" || name === "get_table_records") {
|
||||
if (name === "get_memo" || name === "get_diagram" || name === "get_poster" || name === "get_table_records") {
|
||||
return [effect("read", memo ?? { id: String(args.memoId ?? record?.memoId ?? record?.id ?? "") })];
|
||||
}
|
||||
if (name === "create_memo" || name === "create_table_memo" || name === "create_diagram_memo" || name === "create_infographic_memo" || name === "use_note_template") {
|
||||
return [effect("created", memo)];
|
||||
}
|
||||
if (name === "update_memo" || name === "update_diagram" || name === "restore_memo_revision") {
|
||||
if (name === "update_memo" || name === "update_diagram" || name === "update_poster" || name === "restore_memo_revision") {
|
||||
const id = memo?.id ?? String(args.memoId ?? "");
|
||||
const before = prior(id);
|
||||
return [effect("updated", memo ?? { id, title: before?.title }, { previousRevision: before?.revision })];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { addTableField, createDefaultTableDocument, serializeTableDocument, updateTableCell } from "@edgeever/shared";
|
||||
import { createPosterDocument, serializePosterDocument, parsePosterDocument, markdownToDoc, addTableField, createDefaultTableDocument, serializeTableDocument, updateTableCell } from "@edgeever/shared";
|
||||
import {
|
||||
acquireMaintenanceLease,
|
||||
createMemoEditSession,
|
||||
@@ -211,6 +211,21 @@ describe("database write optimizations", () => {
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
test("protects new poster source from legacy contentJson edits and stale revisions", async () => {
|
||||
const { database, sqlite } = createDatabase();
|
||||
const memo = getSeedMemo(sqlite), actor = { actorType: "user", actorId: "user_owner" };
|
||||
const first = await updateMemoRecord(database, memo.workspace_id, memo.id, { contentMarkdown: serializePosterDocument(createPosterDocument("Poster")) }, actor, "owner");
|
||||
expect(parsePosterDocument(first.memo.contentMarkdown)).not.toBeNull();
|
||||
const legacy = await updateMemoRecord(database, memo.workspace_id, memo.id, { contentJson: markdownToDoc("legacy editor changed preview") }, actor, "owner");
|
||||
expect(legacy.error).toBe("poster_update_required");
|
||||
const next = await updateMemoRecord(database, memo.workspace_id, memo.id, { expectedRevision: first.memo.revision, expectedContentHash: first.memo.contentHash, contentMarkdown: serializePosterDocument(createPosterDocument("New design")) }, actor, "owner");
|
||||
expect(parsePosterDocument(next.memo.contentMarkdown).elements[1].text).toBe("New design");
|
||||
const stale = await updateMemoRecord(database, memo.workspace_id, memo.id, { expectedRevision: first.memo.revision, expectedContentHash: first.memo.contentHash, contentMarkdown: first.memo.contentMarkdown }, actor, "owner");
|
||||
expect(stale.error).toBeTruthy();
|
||||
expect(parsePosterDocument(sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = ?").get(memo.id).content_markdown).elements[1].text).toBe("New design");
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
test("allows only one live maintenance lease owner", async () => {
|
||||
const { database, sqlite } = createDatabase();
|
||||
const first = await acquireMaintenanceLease(database, "demo-reset-test", 60_000);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { callMcpTool } from "./index.ts";
|
||||
import { addTableField, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
|
||||
import { createPosterDocument, serializePosterDocument, parsePosterDocument, addTableField, parseDiagramDocument, parseInfographicDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
|
||||
|
||||
class SqliteD1PreparedStatement {
|
||||
constructor(db, sql, bindings = []) {
|
||||
@@ -72,6 +72,26 @@ const createFixture = (scopes = ["read:memos", "write:memos"]) => {
|
||||
};
|
||||
|
||||
describe("MCP template and AI instruction management", () => {
|
||||
test("poster tools preserve untouched elements, previews, scopes and revision guards", async () => {
|
||||
const { sqlite, auth, context } = createFixture();
|
||||
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters");
|
||||
const poster = { ...createPosterDocument("Original"), previewResourceId: "old_preview" };
|
||||
const created = await callMcpTool(context, auth, "create_memo", { notebookId: "nb_posters", title: "Poster", contentMarkdown: serializePosterDocument(poster) });
|
||||
const read = await callMcpTool(context, auth, "get_poster", { memoId: created.memo.id });
|
||||
const heading = read.poster.elements.find(element => element.type === "text");
|
||||
const input = { memoId: created.memo.id, expectedRevision: read.memo.revision, edits: [{ id: heading.id, patch: { text: "Updated", fill: "#16A06E" } }] };
|
||||
const preview = await callMcpTool(context, auth, "update_poster", { ...input, dryRun: true });
|
||||
expect(preview.poster.elements.find(element => element.id === heading.id).text).toBe("Updated");
|
||||
expect((await callMcpTool(context, auth, "get_poster", { memoId: created.memo.id })).poster.previewResourceId).toBe("old_preview");
|
||||
await expect(callMcpTool(context, { ...auth, scopes: ["read:memos"] }, "update_poster", input)).rejects.toThrow();
|
||||
await expect(callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "get_poster", { memoId: created.memo.id })).rejects.toThrow();
|
||||
const result = await callMcpTool(context, auth, "update_poster", input);
|
||||
expect(result.poster.previewResourceId).toBeUndefined();
|
||||
expect(result.poster.elements.filter(element => element.id !== heading.id)).toEqual(read.poster.elements.filter(element => element.id !== heading.id));
|
||||
expect(parsePosterDocument(result.memo.contentMarkdown).elements.find(element => element.id === heading.id).text).toBe("Updated");
|
||||
await expect(callMcpTool(context, auth, "update_poster", input)).rejects.toThrow();
|
||||
await expect(callMcpTool(context, auth, "update_poster", { ...input, expectedRevision: result.memo.revision, edits: [{ id: "missing", patch: { text: "Bad" } }] })).rejects.toThrow();
|
||||
});
|
||||
test.each([
|
||||
["mind-map", [
|
||||
{ id: "root", label: "Root" },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createPosterDocument } from "@edgeever/shared";
|
||||
import { applyPosterEdits } from "./mcp-poster-tools";
|
||||
|
||||
test("poster edits reject locked elements, duplicate IDs, unknown patch keys and invalid geometry", () => {
|
||||
const poster = createPosterDocument("Hello");
|
||||
const element = poster.elements.find(element => element.type === "text");
|
||||
const input = patch => ({ memoId: "memo", expectedRevision: 1, edits: [{ id: element.id, patch }] });
|
||||
expect(() => applyPosterEdits(input({ text: "Bad" }), { ...poster, elements: poster.elements.map(item => item.id === element.id ? { ...item, locked: true } : item) })).toThrow();
|
||||
expect(() => applyPosterEdits({ ...input({ text: "Bad" }), edits: [input({ text: "One" }).edits[0], input({ text: "Two" }).edits[0]] }, poster)).toThrow();
|
||||
expect(() => applyPosterEdits(input({ resourceId: "other" }), poster)).toThrow();
|
||||
expect(() => applyPosterEdits(input({ width: -1 }), poster)).toThrow();
|
||||
expect(() => applyPosterEdits({ memoId: "memo", expectedRevision: 1 }, poster)).toThrow();
|
||||
});
|
||||
|
||||
test("poster edits update canonical copy, support background changes and retain a preview on no-op", () => {
|
||||
const poster = { ...createPosterDocument("Hello"), previewResourceId: "preview" };
|
||||
const title = poster.elements.find(element => element.type === "text");
|
||||
const changed = applyPosterEdits({ memoId: "memo", expectedRevision: 1, background: "#16A06E", edits: [{ id: title.id, patch: { text: "New heading" } }] }, poster);
|
||||
expect(changed.document.background).toBe("#16A06E");
|
||||
expect(changed.document.elements.find(element => element.id === title.id).sourceText).toBe("New heading");
|
||||
expect(changed.document.previewResourceId).toBeUndefined();
|
||||
const unchanged = applyPosterEdits({ memoId: "memo", expectedRevision: 1, background: poster.background }, poster);
|
||||
expect(unchanged.changed).toBe(false);
|
||||
expect(unchanged.document.previewResourceId).toBe("preview");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import { PosterDocumentSchema, PosterGradientSchema, type PosterDocument } from "@edgeever/shared";
|
||||
import { AppError } from "./app-error";
|
||||
|
||||
const patch = z.object({
|
||||
text: z.string().max(8000).optional(),
|
||||
fill: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
|
||||
fontSize: z.number().finite().min(4).max(8192).optional(),
|
||||
fontWeight: z.enum(["normal", "bold"]).optional(),
|
||||
textAlign: z.enum(["left", "center", "right"]).optional(),
|
||||
x: z.number().finite().min(-8192).max(8192).optional(),
|
||||
y: z.number().finite().min(-8192).max(8192).optional(),
|
||||
width: z.number().finite().min(1).max(8192).optional(),
|
||||
height: z.number().finite().min(1).max(8192).optional(),
|
||||
rotation: z.number().finite().min(-360).max(360).optional(),
|
||||
opacity: z.number().finite().min(0).max(1).optional(),
|
||||
}).strict().refine(value => Object.keys(value).length > 0);
|
||||
const request = z.object({
|
||||
memoId: z.string().min(1),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
dryRun: z.boolean().optional(),
|
||||
background: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
|
||||
backgroundGradient: PosterGradientSchema.nullable().optional(),
|
||||
edits: z.array(z.object({ id: z.string().min(1), patch }).strict()).max(100).optional(),
|
||||
}).strict().refine(value => Boolean(value.edits?.length || value.background || value.backgroundGradient !== undefined));
|
||||
|
||||
export function applyPosterEdits(input: unknown, current: PosterDocument) {
|
||||
const parsed = request.safeParse(input);
|
||||
if (!parsed.success) throw new AppError("invalid_params", "Invalid poster edits", 400);
|
||||
const args = parsed.data;
|
||||
const edits = args.edits ?? [];
|
||||
if (new Set(edits.map(edit => edit.id)).size !== edits.length) throw new AppError("invalid_params", "Duplicate poster element IDs", 400);
|
||||
for (const edit of edits) {
|
||||
const element = current.elements.find(element => element.id === edit.id);
|
||||
if (!element || element.locked) throw new AppError("invalid_params", "Poster element is missing or locked", 400);
|
||||
if (element.type !== "text" && ["text", "fontSize", "fontWeight", "textAlign"].some(key => key in edit.patch)) {
|
||||
throw new AppError("invalid_params", "Typography edits require a text element", 400);
|
||||
}
|
||||
if (element.type === "image" && edit.patch.fill !== undefined) throw new AppError("invalid_params", "Image elements do not have a fill", 400);
|
||||
}
|
||||
const next = PosterDocumentSchema.safeParse({
|
||||
...current,
|
||||
...(args.background !== undefined ? { background: args.background } : {}),
|
||||
...(args.backgroundGradient !== undefined ? { backgroundGradient: args.backgroundGradient ?? undefined } : {}),
|
||||
elements: current.elements.map(element => {
|
||||
const edit = edits.find(edit => edit.id === element.id);
|
||||
if (!edit) return element;
|
||||
return { ...element, ...edit.patch, ...(edit.patch.text !== undefined ? { sourceText: edit.patch.text } : {}) };
|
||||
}),
|
||||
});
|
||||
if (!next.success) throw new AppError("invalid_params", "Poster edits exceed document limits", 400);
|
||||
const document = next.data;
|
||||
if (edits.some(edit => edit.patch.text !== undefined)) {
|
||||
document.copy = {
|
||||
title: current.copy?.title ?? "", subtitle: current.copy?.subtitle ?? "", badge: current.copy?.badge ?? "",
|
||||
};
|
||||
for (const role of ["title", "subtitle", "badge"] as const) {
|
||||
if (!edits.some(edit => edit.patch.text !== undefined && current.elements.some(element => element.id === edit.id && element.role === role))) continue;
|
||||
document.copy[role] = [...new Set(document.elements.filter(element => element.type === "text" && element.role === role)
|
||||
.map(element => element.type === "text" ? element.sourceText ?? element.text : ""))].join("\n");
|
||||
}
|
||||
}
|
||||
const changed = JSON.stringify(document) !== JSON.stringify(PosterDocumentSchema.parse(current));
|
||||
if (changed) document.previewResourceId = undefined;
|
||||
return { document, changed };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { applyPosterEdits } from "./mcp-poster-tools";
|
||||
import { hasPosterDocumentMarker, parsePosterDocument, serializePosterDocument } from "@edgeever/shared";
|
||||
import {
|
||||
AiPromptTemplateCreateSchema,
|
||||
AiPromptTemplateUpdateSchema,
|
||||
@@ -508,6 +510,30 @@ export const callMcpTool = async (
|
||||
diagram: diagramSemanticGraph(document),
|
||||
};
|
||||
}
|
||||
case "get_poster": {
|
||||
assertScope(auth, "read:memos");
|
||||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, getRequiredString(args.memoId, "memoId"));
|
||||
if (!memo) throw new AppError("not_found", "Memo not found", 404);
|
||||
const poster = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!poster) throw new AppError("not_poster", "Memo is not an editable poster", 400);
|
||||
return { memo: { id: memo.id, title: memo.title, notebookId: memo.notebookId, revision: memo.revision }, poster };
|
||||
}
|
||||
case "update_poster": {
|
||||
assertScope(auth, "write:memos");
|
||||
const memoId = getRequiredString(args.memoId, "memoId");
|
||||
const memo = await getMemoDetail(c.env.storage.db, auth.workspaceId, memoId);
|
||||
if (!memo) throw new AppError("not_found", "Memo not found", 404);
|
||||
if (memo.revision !== args.expectedRevision) throw new AppError("revision_conflict", "Poster changed. Read it again before editing.", 409);
|
||||
const current = parsePosterDocument(memo.contentMarkdown);
|
||||
if (!current) throw new AppError("not_poster", "Memo is not an editable poster", 400);
|
||||
const mutation = applyPosterEdits(args, current);
|
||||
if (args.dryRun === true || !mutation.changed) return { dryRun: args.dryRun === true, memo, poster: mutation.document, changed: mutation.changed };
|
||||
const result = await updateMemoRecord(c.env.storage.db, auth.workspaceId, memoId, {
|
||||
expectedRevision: args.expectedRevision, contentMarkdown: serializePosterDocument(mutation.document),
|
||||
}, getAuditActor(c), getActorLabel(c));
|
||||
if (result.error !== undefined) throw new AppError(result.error, result.message, result.status ?? 400);
|
||||
return { memo: result.memo, poster: mutation.document, changed: true };
|
||||
}
|
||||
case "get_diagram": {
|
||||
assertScope(auth, "read:memos");
|
||||
const memoId = getRequiredString(args.memoId, "memoId");
|
||||
@@ -585,6 +611,9 @@ export const callMcpTool = async (
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (existing && hasPosterDocumentMarker(existing.contentMarkdown)) {
|
||||
throw new AppError("poster_update_required", "Poster content cannot be replaced through update_memo. Use get_poster then update_poster.", 400);
|
||||
}
|
||||
if (existing && hasInfographicDocumentMarker(existing.contentMarkdown)) {
|
||||
throw new AppError(
|
||||
"infographic_update_required",
|
||||
|
||||
@@ -363,6 +363,37 @@ const MCP_TOOL_DEFINITIONS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_poster",
|
||||
description: "Read an editable poster's canvas, element IDs, text, colors and geometry. Read before update_poster; never decode or replace poster Markdown.",
|
||||
inputSchema: { type: "object", required: ["memoId"], additionalProperties: false, properties: { memoId: { type: "string", minLength: 1 } } },
|
||||
},
|
||||
{
|
||||
name: "update_poster",
|
||||
description: "Edit an existing poster's text, colors, typography and element positions. Preserve unmentioned elements and locked elements. Read get_poster first and pass its expectedRevision. Use dryRun to preview. After changes the editor regenerates the preview image.",
|
||||
inputSchema: {
|
||||
type: "object", required: ["memoId", "expectedRevision"], additionalProperties: false,
|
||||
properties: {
|
||||
memoId: { type: "string", minLength: 1 }, expectedRevision: { type: "integer", minimum: 0 }, dryRun: { type: "boolean" },
|
||||
background: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" },
|
||||
backgroundGradient: { type: ["object", "null"], additionalProperties: false, required: ["kind", "from", "to"], properties: {
|
||||
kind: { type: "string", enum: ["linear", "radial"] }, from: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, to: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" }, angle: { type: "number", minimum: -360, maximum: 360 },
|
||||
} },
|
||||
edits: { type: "array", maxItems: 100, items: {
|
||||
type: "object", required: ["id", "patch"], additionalProperties: false,
|
||||
properties: { id: { type: "string", minLength: 1 }, patch: {
|
||||
type: "object", additionalProperties: false, properties: {
|
||||
text: { type: "string", maxLength: 8000 }, fill: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" },
|
||||
fontSize: { type: "number", minimum: 4, maximum: 8192 }, fontWeight: { type: "string", enum: ["normal", "bold"] }, textAlign: { type: "string", enum: ["left", "center", "right"] },
|
||||
x: { type: "number", minimum: -8192, maximum: 8192 }, y: { type: "number", minimum: -8192, maximum: 8192 },
|
||||
width: { type: "number", minimum: 1, maximum: 8192 }, height: { type: "number", minimum: 1, maximum: 8192 },
|
||||
rotation: { type: "number", minimum: -360, maximum: 360 }, opacity: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
} },
|
||||
} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_diagram",
|
||||
description:
|
||||
@@ -979,6 +1010,7 @@ const READ_ONLY_MCP_TOOLS = new Set([
|
||||
"get_memo",
|
||||
"get_table_records",
|
||||
"get_diagram",
|
||||
"get_poster",
|
||||
"list_memo_resources",
|
||||
"list_resources",
|
||||
"list_memo_revisions",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
markdownToDoc,
|
||||
getDiagramSummary,
|
||||
getInfographicSummary,
|
||||
getPosterSummary,
|
||||
getTableSummary,
|
||||
type MemoSummary,
|
||||
} from "@edgeever/shared";
|
||||
@@ -73,6 +74,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
|
||||
createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))),
|
||||
...getDiagramSummary(row.content_markdown),
|
||||
...getInfographicSummary(row.content_markdown),
|
||||
...getPosterSummary(row.content_markdown),
|
||||
...getTableSummary(row.content_markdown),
|
||||
tags: parseJsonArray(row.tags_json),
|
||||
isPinned: Boolean(row.is_pinned),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasPosterDocumentMarker, parsePosterDocument } from "@edgeever/shared";
|
||||
import {
|
||||
createExcerpt,
|
||||
DEFAULT_MEMO_TITLE,
|
||||
@@ -1346,6 +1347,12 @@ export const updateMemoRecord = async (
|
||||
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
|
||||
const excerpt = createExcerpt(contentText);
|
||||
const notebookId = input.notebookId ?? current.notebook_id;
|
||||
if (hasPosterDocumentMarker(current.content_markdown) && !parsePosterDocument(contentMarkdown)) {
|
||||
return { error: "poster_update_required", message: "Poster source must be edited on a supported client.", status: 409 };
|
||||
}
|
||||
if (hasPosterDocumentMarker(contentMarkdown) && !parsePosterDocument(contentMarkdown)) {
|
||||
return { error: "invalid_poster", message: "Invalid poster source.", status: 400 };
|
||||
}
|
||||
const currentTable = parseTableDocument(current.content_markdown);
|
||||
const nextTable = parseTableDocument(contentMarkdown);
|
||||
if (currentTable && !nextTable) {
|
||||
|
||||
@@ -32,6 +32,10 @@ enum TipTapContentSource: Sendable {
|
||||
let stripped = stripStructuredTableMarker(markdown)
|
||||
return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)")
|
||||
}
|
||||
if markdown.contains("<!-- edgeever-poster-v1:") {
|
||||
let stripped = markdown.replacingOccurrences(of: #"<!--\s*edgeever-poster-v1:[\s\S]*?-->"#, with: "", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Decision(useJSON: false, payload: stripped, fingerprint: "poster:\(stripped)")
|
||||
}
|
||||
if markdown.contains("<!-- edgeever-infographic-v1:") {
|
||||
let stripped = stripInfographicMarker(markdown)
|
||||
return Decision(useJSON: false, payload: stripped, fingerprint: "infographic:\(stripped)")
|
||||
|
||||
@@ -959,7 +959,9 @@ struct MemoDetailView: View {
|
||||
: isVisualDiagram(memo)
|
||||
? env.preferences.t("图表标题", en: "Diagram title", pl: "Tytuł diagramu")
|
||||
: env.preferences.t("编辑笔记标题", en: "Edit note title", pl: "Edytuj tytuł notatki"))
|
||||
.accessibilityHint(isInfographic(memo)
|
||||
.accessibilityHint(memo.contentMarkdown.contains("<!-- edgeever-poster-v1:")
|
||||
? env.preferences.t("海报请在 Web 或桌面端编辑", en: "Edit posters on Web or desktop", pl: "Plakaty edytuj w wersji webowej lub desktopowej")
|
||||
: isInfographic(memo)
|
||||
? env.preferences.t("信息图请在 Web 或桌面端编辑", en: "Edit infographics on Web or desktop", pl: "Infografiki edytuj w wersji webowej lub desktopowej")
|
||||
: isStructuredTable(memo)
|
||||
? env.preferences.t("多维表格请在 Web 或桌面端编辑", en: "Edit databases on Web or desktop", pl: "Bazy danych edytuj w wersji webowej lub desktopowej")
|
||||
@@ -995,7 +997,7 @@ struct MemoDetailView: View {
|
||||
}
|
||||
|
||||
private func blocksRichTextEdit(_ memo: MemoDetail) -> Bool {
|
||||
isVisualDiagram(memo) || isStructuredTable(memo) || isInfographic(memo)
|
||||
isVisualDiagram(memo) || isStructuredTable(memo) || isInfographic(memo) || memo.contentMarkdown.contains("<!-- edgeever-poster-v1:")
|
||||
}
|
||||
|
||||
private func refreshSyncStatus() {
|
||||
|
||||
@@ -37,6 +37,14 @@ final class TipTapContentSourceTests: XCTestCase {
|
||||
XCTAssertTrue(decision.payload.contains("infographic list-row-simple-horizontal-arrow"))
|
||||
}
|
||||
|
||||
func testPosterViewerKeepsPreviewAndStripsSource() {
|
||||
let markdown = "\n\n<!-- edgeever-poster-v1:abc -->"
|
||||
let decision = TipTapContentSource.resolve(mode: .viewer, documentJSON: "{}", markdown: markdown)
|
||||
XCTAssertFalse(decision.useJSON)
|
||||
XCTAssertTrue(decision.payload.contains("res_preview/blob"))
|
||||
XCTAssertFalse(decision.payload.contains("edgeever-poster-v1"))
|
||||
}
|
||||
|
||||
func testEditorMarkdownRemainsAuthoritativeForRichStructures() {
|
||||
let markdown = """
|
||||
1. first
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared";
|
||||
import { getMobileVisualDiagramKind, hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram";
|
||||
import { createPosterDocument, serializePosterDocument, createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeInfographicDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared";
|
||||
import { hasMobilePoster, 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,15 @@ describe("mobile visual diagram viewer", () => {
|
||||
expect(JSON.stringify(resolveMobileMemoViewerContent(null, broken))).not.toContain("edgeever-table-v1");
|
||||
});
|
||||
|
||||
test("shows a poster preview and keeps editable source out of the native reader", () => {
|
||||
const markdown = serializePosterDocument({ ...createPosterDocument("Poster"), previewResourceId: "res_preview" });
|
||||
expect(hasMobilePoster(markdown)).toBe(true);
|
||||
const doc = resolveMobileMemoViewerContent(null, markdown);
|
||||
expect(JSON.stringify(doc)).toContain("res_preview/blob");
|
||||
expect(JSON.stringify(doc)).not.toContain("edgeever-poster-v1");
|
||||
expect(hasMobilePoster("text\n<!-- edgeever-poster-v1:broken -->")).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
hasDiagramDocumentMarker,
|
||||
hasInfographicDocumentMarker,
|
||||
hasPosterDocumentMarker,
|
||||
stripPosterDocumentMarker,
|
||||
hasTableDocumentMarker,
|
||||
markdownToDoc,
|
||||
parseDiagramDocument,
|
||||
@@ -27,11 +29,14 @@ export const hasMobileStructuredTable = (contentMarkdown: string) =>
|
||||
export const hasMobileInfographic = (contentMarkdown: string) =>
|
||||
hasInfographicDocumentMarker(contentMarkdown);
|
||||
|
||||
export const hasMobilePoster = (contentMarkdown: string) => hasPosterDocumentMarker(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,
|
||||
contentMarkdown: string,
|
||||
) => {
|
||||
if (hasPosterDocumentMarker(contentMarkdown)) return markdownToDoc(stripPosterDocumentMarker(contentMarkdown));
|
||||
if (parseDiagramDocument(contentMarkdown)) {
|
||||
return { type: "doc", content: [{ type: "paragraph" }] } satisfies TiptapDoc;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,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 { hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram";
|
||||
import { hasMobilePoster, hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram";
|
||||
import { safeDomCall } from "../lib/safe-dom-call";
|
||||
import {
|
||||
getMobileImageTarget,
|
||||
@@ -555,7 +555,7 @@ export const MemoDetailModal = ({
|
||||
() => (memo ? hasMobileStructuredTable(memo.contentMarkdown) : false),
|
||||
[memo]
|
||||
);
|
||||
const locksRichTextEdit = isVisualDiagram || isStructuredTable || Boolean(memo && hasMobileInfographic(memo.contentMarkdown));
|
||||
const locksRichTextEdit = isVisualDiagram || isStructuredTable || Boolean(memo && (hasMobileInfographic(memo.contentMarkdown) || hasMobilePoster(memo.contentMarkdown)));
|
||||
const visualDiagramJson = useMemo(() => {
|
||||
const diagram = memo ? parseDiagramDocument(memo.contentMarkdown) : null;
|
||||
return diagram ? JSON.stringify(diagram) : undefined;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"ai": "7.0.58",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"docx-preview": "0.4.1",
|
||||
"fabric": "7.4.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lowlight": "^3.3.0",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
Anton (Anton)
|
||||
Source: https://fonts.google.com/specimen/Anton
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
Bebas Neue (Bebas Neue)
|
||||
Source: https://fonts.google.com/specimen/Bebas+Neue
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2019 The Bebas Neue Project Authors (https://github.com/dharmatype/Bebas-Neue)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
DM Serif Display (DM Serif Display)
|
||||
Source: https://fonts.google.com/specimen/DM+Serif+Display
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2014 - 2017 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. Copyright 2019 Google LLC.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,34 @@
|
||||
抖音美好体 (Douyin Sans)
|
||||
Source: https://github.com/bytedance/fonts
|
||||
This copy: unmodified original
|
||||
|
||||
Copyright (c) , 2023 Beijing Zitiao Network Technology Co. Ltd.
|
||||
with Reserved Font Names "Douyin", "抖音", "抖音美好".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
未来荧黑 (Glow Sans SC Heavy)
|
||||
Source: https://github.com/welai/glow-sans
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Glow Sans (c) 2021 Project Welai
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
站酷庆科黄油体 (ZCOOL QingKe HuangYou)
|
||||
Source: https://fonts.google.com/specimen/ZCOOL+QingKe+HuangYou
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2018 The ZCOOL QingKe HuangYou Project Authors (https://www.github.com/googlefonts/zcool-qingke-huangyou)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
Inter (Inter)
|
||||
Source: https://fonts.google.com/specimen/Inter
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
JetBrains Mono (JetBrains Mono)
|
||||
Source: https://fonts.google.com/specimen/JetBrains+Mono
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
江城圆体 (JiangCheng YuanTi)
|
||||
Source: https://www.npmjs.com/package/@fontpkg/jiang-cheng-yuan-ti
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
JiangCheng YuanTi
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
站酷快乐体 (ZCOOL KuaiLe)
|
||||
Source: https://fonts.google.com/specimen/ZCOOL+KuaiLe
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2018 The ZCOOL KuaiLe Project Authors (https://github.com/googlefonts/zcool-kuaile)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
马善政楷书 (Ma Shan Zheng)
|
||||
Source: https://fonts.google.com/specimen/Ma+Shan+Zheng
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2018 The MaShanZheng Project Authors (https://github.com/googlefonts/mashanzheng)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
思源黑体 Heavy (Noto Sans SC Black)
|
||||
Source: https://fonts.google.com/noto/specimen/Noto+Sans+SC
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
思源黑体 (Noto Sans SC)
|
||||
Source: https://fonts.google.com/noto/specimen/Noto+Sans+SC
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
思源宋体 Heavy (Noto Serif SC Black)
|
||||
Source: https://fonts.google.com/noto/specimen/Noto+Serif+SC
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
(c) 2017-2024 Adobe (http://www.adobe.com/).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
思源宋体 (Noto Serif SC)
|
||||
Source: https://fonts.google.com/noto/specimen/Noto+Serif+SC
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
(c) 2017-2024 Adobe (http://www.adobe.com/).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
得意黑 (Smiley Sans)
|
||||
Source: https://github.com/atelier-anchor/smiley-sans
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright © 2022–2024 atelierAnchor. All rights reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
Space Grotesk (Space Grotesk)
|
||||
Source: https://fonts.google.com/specimen/Space+Grotesk
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,34 @@
|
||||
霞鹜文楷 (LXGW WenKai)
|
||||
Source: https://github.com/lxgw/LxgwWenKai
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2021-2025 LXGW (https://github.com/lxgw/LxgwWenKai)
|
||||
Copyright 2020 The Klee Project Authors (https://github.com/fontworks-fonts/Klee)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,33 @@
|
||||
朱雀仿宋 (Zhuque Fangsong)
|
||||
Source: https://github.com/TrionesType/zhuque
|
||||
This copy: subset and converted to WOFF2 by Qiaomu Cover Design
|
||||
|
||||
Copyright 2023 JadeFoci
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at: https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
- 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
- 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
- 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
- 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
- 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -316,6 +316,7 @@ import {
|
||||
} from "./editor/editor-pane-helpers";
|
||||
|
||||
type EditorPaneProps = {
|
||||
onCreatePoster?: (memo: MemoDetail, text: string) => Promise<void>;
|
||||
memo: MemoDetail | null;
|
||||
repository: EdgeEverRepository;
|
||||
desktopFocusMode: boolean;
|
||||
@@ -416,6 +417,7 @@ const RichEditorPane = ({
|
||||
onRestored,
|
||||
onMobileDefaultEditConsumed,
|
||||
onSaveAsTemplate,
|
||||
onCreatePoster,
|
||||
searchFocusToken,
|
||||
replaceFocusToken,
|
||||
aiAssistantOpenToken,
|
||||
@@ -4112,6 +4114,11 @@ const RichEditorPane = ({
|
||||
onSearch={() => openNoteSearch()}
|
||||
textNoteMenuItems={(
|
||||
<>
|
||||
{onCreatePoster && !effectiveReadOnly && <DropdownMenuItem disabled={!memo || saveMutation.isPending} onSelect={() => {
|
||||
const selection = editor?.state.selection;
|
||||
const text = selection && !selection.empty ? editor?.state.doc.textBetween(selection.from, selection.to, "\n") ?? "" : editor?.getText() ?? memo?.contentText ?? "";
|
||||
void (async () => { if (!memo) return; if (hasUnsavedChangesRef.current) await saveMutation.mutateAsync(); await onCreatePoster(memoRef.current ?? memo, text); })().catch((error) => setSaveState("error"));
|
||||
}}>{t("poster.createFromNote")}</DropdownMenuItem>}
|
||||
{!isMobileViewport && !useMobilePlainTextEditor && !useMarkdownSourceEditor && (
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Component, GitFork, PieChart, Table2, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { FileText, Component, GitFork, PieChart, Table2, 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-xs leading-relaxed text-slate-600">{memo.diagramPreview.labels.join(" · ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : memo.poster ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500"><FileText className="h-3.5 w-3.5" aria-hidden="true" /><span>{t("poster.name")}</span></div>
|
||||
) : memo.infographic ? (
|
||||
<div className="flex items-center text-xs text-slate-500">
|
||||
<PieChart className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { IconTooltip } from "@/components/editor/EditorPaneChrome";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const MemoEditorHeaderActions = ({
|
||||
@@ -43,17 +44,16 @@ export const MemoEditorHeaderActions = ({
|
||||
/>
|
||||
<ExecutionCenterButton className="h-8 w-8" onClick={onOpenExecutionCenter} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconTooltip label={t("editor.more")}><DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={moreButtonClassName}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={t("editor.more")}
|
||||
aria-label={t("editor.moreAria")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenuTrigger></IconTooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("border border-slate-200 bg-card py-1 shadow-md", moreMenuClassName)}
|
||||
|
||||
@@ -673,7 +673,7 @@ export const MemoListPane = ({
|
||||
|
||||
const handleCopyContextMemoToWeChat = async () => {
|
||||
const memo = memoContextMenu?.memo;
|
||||
if (!memo || memo.diagramKind || memo.structuredTable || memo.infographic || wechatCopyPendingRef.current) return;
|
||||
if (!memo || memo.diagramKind || memo.structuredTable || memo.infographic || memo.poster || wechatCopyPendingRef.current) return;
|
||||
setMemoContextMenu(null);
|
||||
wechatCopyPendingRef.current = true;
|
||||
if (wechatCopyNoticeTimerRef.current !== null) {
|
||||
@@ -1674,7 +1674,7 @@ export const MemoListPane = ({
|
||||
<Share2 className="h-4 w-4 text-slate-500" />
|
||||
{t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")}
|
||||
</DropdownMenuItem>
|
||||
{!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && (
|
||||
{!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && !memoContextMenu.memo.poster && (
|
||||
<DropdownMenuItem
|
||||
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
|
||||
onClick={() => void handleCopyContextMemoToWeChat()}
|
||||
|
||||
@@ -346,6 +346,11 @@ const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: NoteCreat
|
||||
<span className="min-w-0 flex-1 truncate">{t("infographic.name")}</span>
|
||||
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 text-xs leading-5" onSelect={() => onCreateMemo("poster")}>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("poster.name")}</span>
|
||||
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 text-xs leading-5" onSelect={() => onCreateMemo("table")}>
|
||||
<Table2 className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -526,11 +526,27 @@ export const TableEditorPane = ({
|
||||
const pendingUploadIdsRef = useRef(new Set<string>());
|
||||
const savedSnapshotRef = useRef(parsed ? snapshotOf(memo.title ?? "", parsed) : "");
|
||||
const saveRef = useRef<() => void>(() => undefined);
|
||||
if (memo.revision >= memoRef.current.revision) memoRef.current = memo;
|
||||
titleRef.current = title;
|
||||
documentRef.current = document;
|
||||
editingRef.current = editing;
|
||||
|
||||
useEffect(() => {
|
||||
// Keep the displayed table and its save base in sync after AI/server edits.
|
||||
// An active local edit must retain its original revision for conflict checks.
|
||||
if (!parsed || dirty || saving || editing || memo.revision < memoRef.current.revision) return;
|
||||
const nextTitle = memo.title ?? "";
|
||||
const nextSnapshot = snapshotOf(nextTitle, parsed);
|
||||
memoRef.current = memo;
|
||||
if (nextSnapshot === savedSnapshotRef.current) return;
|
||||
setTitle(nextTitle);
|
||||
titleRef.current = nextTitle;
|
||||
setDocument(parsed);
|
||||
documentRef.current = parsed;
|
||||
savedSnapshotRef.current = nextSnapshot;
|
||||
setSaveError(null);
|
||||
setSaveFailed(false);
|
||||
}, [dirty, editing, memo, parsed, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
editSessionRef.current = null;
|
||||
setEditSessionReady(false);
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
} from "@/lib/mobile-editor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
|
||||
import { createDefaultDiagramDocument, createDefaultInfographicDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, 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 { createDefaultDiagramDocument, createDefaultInfographicDocument, hasPosterDocumentMarker, posterFallbackMarkdown, serializePosterDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookScopeIds, 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,
|
||||
@@ -143,6 +143,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 PosterEditorPane = lazy(() => import("./PosterEditorPane"));
|
||||
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 })));
|
||||
@@ -1231,7 +1232,7 @@ export const WorkspaceApp = ({
|
||||
|
||||
const revealCreatedMemo = (memo: MemoDetail) => {
|
||||
const targetNotebookId = memo.notebookId;
|
||||
const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown) || parseInfographicDocument(memo.contentMarkdown));
|
||||
const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown) || parseInfographicDocument(memo.contentMarkdown) || hasPosterDocumentMarker(memo.contentMarkdown));
|
||||
|
||||
setMemoView("notebook");
|
||||
setSearch("");
|
||||
@@ -1457,6 +1458,7 @@ export const WorkspaceApp = ({
|
||||
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
|
||||
const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown);
|
||||
const selectedTableNote = hasTableDocumentMarker(selectedMemo?.contentMarkdown);
|
||||
const selectedPosterNote = hasPosterDocumentMarker(selectedMemo?.contentMarkdown);
|
||||
const selectedInfographicNote = Boolean(parseInfographicDocument(selectedMemo?.contentMarkdown));
|
||||
const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed);
|
||||
const desktopFocusModeActive = Boolean(
|
||||
@@ -1861,6 +1863,18 @@ export const WorkspaceApp = ({
|
||||
setTemplatesOpen(false);
|
||||
setMobileBottomNavActive("home");
|
||||
creatingMemoSelectionRef.current = true;
|
||||
if (kind === "poster") {
|
||||
void import("@/lib/poster-design/templates").then(async ({ buildPosterDesign }) => {
|
||||
const poster = await buildPosterDesign({ id: "folio", title: t("poster.defaultHeading"), subtitle: t("poster.defaultSubtitle"), width: 1080, height: 1440, zh: i18n.language.startsWith("zh") });
|
||||
createMemoMutation.mutate({ notebookId: targetNotebookId, title: t("poster.name"), contentJson: markdownToDoc(posterFallbackMarkdown(poster)), contentMarkdown: serializePosterDocument(poster), tags: [] });
|
||||
}).catch(() => {
|
||||
createMemoInFlightRef.current = false;
|
||||
clearPendingCreatedMemo();
|
||||
setCreatedMemoEditId(null);
|
||||
setAppNoticeDialog({ title: t("poster.name"), description: t("poster.error") });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (kind === "infographic") {
|
||||
const infographic = createDefaultInfographicDocument();
|
||||
createMemoMutation.mutate({
|
||||
@@ -3226,7 +3240,7 @@ export const WorkspaceApp = ({
|
||||
const openTextNote = selectedMemo?.id === memoId
|
||||
&& !selectedDiagram
|
||||
&& !selectedTableNote
|
||||
&& !selectedInfographicNote;
|
||||
&& !selectedInfographicNote && !selectedPosterNote;
|
||||
if (openTextNote) {
|
||||
memoDocumentActionIdRef.current += 1;
|
||||
setMemoDocumentActionRequest({
|
||||
@@ -3394,6 +3408,43 @@ export const WorkspaceApp = ({
|
||||
}}
|
||||
onOpenCompanionNote={handleOpenPluginNote}
|
||||
/>
|
||||
) : selectedMemo && selectedPosterNote ? (
|
||||
<PosterEditorPane
|
||||
key={selectedMemo.id}
|
||||
memo={selectedMemo}
|
||||
notebooks={notebooks}
|
||||
repository={repository}
|
||||
readOnly={memoView === "trash" || selectedMemo.isDeleted}
|
||||
desktopFocusMode={desktopFocusModeActive}
|
||||
onBackToList={() => {
|
||||
clearPendingCreatedMemo();
|
||||
setSelectedMemoId(null);
|
||||
setActivePane("memos");
|
||||
}}
|
||||
onOpenExecutionCenter={handleOpenExecutionCenter}
|
||||
onToggleDesktopFocusMode={toggleDesktopFocusMode}
|
||||
aiAssistantOpenToken={noteAiAssistantOpenToken}
|
||||
shortcutSettings={shortcutSettings}
|
||||
companionAvailable={authRequired && Boolean(user) && !demoMode}
|
||||
beforeCompanionApply={async () => {
|
||||
const { assertCompanionChangesSynced } = await import("@/lib/companion-actions");
|
||||
await assertCompanionChangesSynced(localDataScope);
|
||||
}}
|
||||
onCompanionNotesChanged={async () => {
|
||||
const result = await refreshWorkspaceFromServer("manual");
|
||||
if ("skipped" in result && result.skipped) throw new Error("Workspace refresh was skipped.");
|
||||
}}
|
||||
onOpenCompanionNote={handleOpenPluginNote}
|
||||
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 && selectedInfographicNote ? (
|
||||
<InfographicEditorPane
|
||||
key={selectedMemo.id}
|
||||
@@ -3456,6 +3507,11 @@ export const WorkspaceApp = ({
|
||||
/>
|
||||
) : (
|
||||
<EditorPane
|
||||
onCreatePoster={async (source, text) => {
|
||||
const { buildPosterDesign } = await import("@/lib/poster-design/templates");
|
||||
const poster = { ...await buildPosterDesign({ id: "folio", title: (source.title ?? t("poster.defaultHeading")).slice(0, 160), subtitle: text.slice(0, 240), width: 1080, height: 1440, zh: i18n.language.startsWith("zh") }), sourceMemoId: source.id };
|
||||
await createMemoMutation.mutateAsync({ notebookId: source.notebookId, title: `${source.title || t("poster.name")} · ${t("poster.name")}`, contentJson: markdownToDoc(posterFallbackMarkdown(poster)), contentMarkdown: serializePosterDocument(poster), tags: source.tags });
|
||||
}}
|
||||
demoMode={demoMode}
|
||||
onOpenExecutionCenter={handleOpenExecutionCenter}
|
||||
memo={selectedMemo}
|
||||
|
||||
@@ -116,7 +116,12 @@ export const MobileBottomNav = ({
|
||||
<span className="min-w-0 flex-1 truncate">{t("infographic.name")}</span>
|
||||
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 text-xs leading-5" onSelect={() => onCreateMemo("table")}>
|
||||
<DropdownMenuItem className="gap-2 text-xs leading-5" onSelect={() => onCreateMemo("poster")}>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("poster.name")}</span>
|
||||
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 text-xs leading-5" onSelect={() => onCreateMemo("table")}>
|
||||
<Table2 className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
|
||||
<span className={BETA_BADGE_CLASSNAME}>Beta</span>
|
||||
|
||||
@@ -1004,6 +1004,9 @@ function AiSidebarSession({
|
||||
active.current = null;
|
||||
locked.current = false;
|
||||
setBusy(false);
|
||||
// Local agents write through MCP, outside the renderer's query cache.
|
||||
// Also refresh failed runs, which may have completed some writes.
|
||||
void onCompanionNotesChanged?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Circle,
|
||||
ImagePlus,
|
||||
LoaderCircle,
|
||||
RectangleHorizontal,
|
||||
Search,
|
||||
Sparkles,
|
||||
Star,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EdgeEverRepository } from "@/lib/repository";
|
||||
import type { ResourceListItem } from "@edgeever/shared";
|
||||
import { PosterSection } from "./PosterControls";
|
||||
function ResourceThumbnail({
|
||||
resource,
|
||||
repository,
|
||||
}: {
|
||||
resource: ResourceListItem;
|
||||
repository: EdgeEverRepository;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null),
|
||||
[visible, setVisible] = useState(false),
|
||||
[url, setUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
if (host.current) observer.observe(host.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false,
|
||||
url: string | undefined;
|
||||
void repository
|
||||
.readResource(resource.id)
|
||||
.then((blob) => {
|
||||
url = URL.createObjectURL(blob);
|
||||
if (cancelled) URL.revokeObjectURL(url);
|
||||
else setUrl(url);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [visible, resource.id, repository]);
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
className="flex aspect-square items-center justify-center overflow-hidden rounded-md bg-slate-100"
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={resource.filename ?? "Image"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5 text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function PosterAssetsPanel({
|
||||
repository,
|
||||
disabled,
|
||||
onUpload,
|
||||
onResource,
|
||||
onShape,
|
||||
onText,
|
||||
}: {
|
||||
repository: EdgeEverRepository;
|
||||
disabled: boolean;
|
||||
onUpload: () => void;
|
||||
onResource: (resource: ResourceListItem) => void;
|
||||
onShape: (shape: string) => void;
|
||||
onText: (style: "heading" | "subtitle" | "body" | "badge") => void;
|
||||
}) {
|
||||
const { t } = useTranslation(),
|
||||
[resources, setResources] = useState<ResourceListItem[]>([]),
|
||||
[search, setSearch] = useState(""),
|
||||
[loading, setLoading] = useState(true),
|
||||
[failed, setFailed] = useState(false),
|
||||
[retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
void repository
|
||||
.listResources()
|
||||
.then(({ resources }) => {
|
||||
if (!cancelled)
|
||||
setResources(
|
||||
resources.filter(
|
||||
(resource) =>
|
||||
["image/png", "image/jpeg", "image/webp"].includes(
|
||||
resource.mimeType ?? "",
|
||||
) && !(resource.filename ?? "").startsWith("poster-preview"),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [repository, retry]);
|
||||
const shapes = [
|
||||
{ id: "rect", Icon: RectangleHorizontal },
|
||||
{ id: "ellipse", Icon: Circle },
|
||||
{ id: "pill", Icon: RectangleHorizontal },
|
||||
{ id: "line", Icon: RectangleHorizontal },
|
||||
{ id: "arrow", Icon: ArrowUpRight },
|
||||
{ id: "star", Icon: Star },
|
||||
{ id: "sparkle", Icon: Sparkles },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<PosterSection title={t("poster.text")}>
|
||||
<div className="space-y-2">
|
||||
{(["heading", "subtitle", "body", "badge"] as const).map(
|
||||
(style, index) => (
|
||||
<button
|
||||
key={style}
|
||||
disabled={disabled}
|
||||
onClick={() => onText(style)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-slate-200 bg-card p-3 text-left hover:border-emerald-400"
|
||||
>
|
||||
<Type className="h-4 w-4 text-slate-400" />
|
||||
<span
|
||||
className={
|
||||
index === 0
|
||||
? "text-xl font-bold"
|
||||
: index === 1
|
||||
? "text-base font-medium"
|
||||
: "text-xs"
|
||||
}
|
||||
>
|
||||
{t(`poster.design.textStyles.${style}`)}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</PosterSection>
|
||||
<PosterSection title={t("poster.design.shape")}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{shapes.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
disabled={disabled}
|
||||
aria-label={t(`poster.design.shapes.${id}`)}
|
||||
onClick={() => onShape(id)}
|
||||
className="flex aspect-square items-center justify-center rounded-lg border border-slate-200 bg-card hover:border-emerald-400"
|
||||
>
|
||||
<Icon className="h-6 w-6 stroke-[1.5]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PosterSection>
|
||||
<PosterSection title={t("poster.design.images")}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onClick={onUpload}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
{t("poster.design.uploadImage")}
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-3.5 w-3.5 text-slate-400" />
|
||||
<Input
|
||||
aria-label={t("poster.design.searchImages")}
|
||||
placeholder={t("poster.design.searchImages")}
|
||||
className="h-8 pl-7 text-xs"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : failed ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setRetry(retry + 1)}
|
||||
>
|
||||
{t("poster.retry")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{resources
|
||||
.filter((resource) =>
|
||||
(resource.filename ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
)
|
||||
.map((resource) => (
|
||||
<button
|
||||
key={resource.id}
|
||||
disabled={disabled}
|
||||
className="space-y-1 text-left"
|
||||
onClick={() => onResource(resource)}
|
||||
>
|
||||
<ResourceThumbnail
|
||||
resource={resource}
|
||||
repository={repository}
|
||||
/>
|
||||
<p className="truncate text-[10px] text-slate-500">
|
||||
{resource.filename}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] leading-relaxed text-slate-400">
|
||||
{t("poster.design.imageLibraryHint")}
|
||||
</p>
|
||||
</PosterSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PosterDocument } from "@edgeever/shared";
|
||||
import { PLATFORMS } from "@/lib/poster-design/templates";
|
||||
import { PosterColor, PosterNumber, PosterSection } from "./PosterControls";
|
||||
export function PosterCanvasSettings({
|
||||
document,
|
||||
disabled,
|
||||
zh,
|
||||
onChange,
|
||||
onResize,
|
||||
onPlatform,
|
||||
}: {
|
||||
document: PosterDocument;
|
||||
disabled: boolean;
|
||||
zh: boolean;
|
||||
onChange: (document: PosterDocument) => void;
|
||||
onResize: (width: number, height: number) => void;
|
||||
onPlatform: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const gradient = document.backgroundGradient;
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<PosterSection title={t("poster.design.platform")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<button
|
||||
key={platform.id}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border p-2 text-left transition ${platform.width === document.width && platform.height === document.height && (!document.platformId || document.platformId === platform.id) ? "border-emerald-500 bg-emerald-50/60" : "border-slate-200 hover:border-emerald-300"}`}
|
||||
onClick={() => onPlatform(platform.id)}
|
||||
>
|
||||
<div className="truncate text-[11px] font-medium">
|
||||
{zh ? platform.zh : platform.en}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] tabular-nums text-slate-400">
|
||||
{platform.width} × {platform.height}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PosterSection>
|
||||
<PosterSection title={t("poster.design.customSize")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PosterNumber
|
||||
label={t("poster.design.width")}
|
||||
min={320}
|
||||
max={4096}
|
||||
value={document.width}
|
||||
disabled={disabled}
|
||||
onChange={(width) => onResize(Math.round(width), document.height)}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.height")}
|
||||
min={320}
|
||||
max={4096}
|
||||
value={document.height}
|
||||
disabled={disabled}
|
||||
onChange={(height) => onResize(document.width, Math.round(height))}
|
||||
/>
|
||||
</div>
|
||||
</PosterSection>
|
||||
<PosterSection title={t("poster.background")}>
|
||||
<div className="grid grid-cols-2 rounded-md bg-slate-100/70 p-1 text-xs">
|
||||
{[false, true].map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={disabled}
|
||||
aria-pressed={Boolean(gradient) === value}
|
||||
className={`rounded px-2 py-1.5 ${Boolean(gradient) === value ? "bg-card shadow-sm" : "text-slate-500"}`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...document,
|
||||
backgroundGradient: value
|
||||
? {
|
||||
kind: "linear",
|
||||
from: document.background,
|
||||
to: "#baf264",
|
||||
angle: 120,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(value ? "poster.design.gradient" : "poster.design.solid")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PosterColor
|
||||
label={t(
|
||||
gradient ? "poster.design.gradientFrom" : "poster.background",
|
||||
)}
|
||||
value={gradient?.from ?? document.background}
|
||||
disabled={disabled}
|
||||
swatches
|
||||
onChange={(color) =>
|
||||
onChange({
|
||||
...document,
|
||||
background: color,
|
||||
backgroundGradient: gradient
|
||||
? { ...gradient, from: color }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{gradient && (
|
||||
<>
|
||||
<PosterColor
|
||||
label={t("poster.design.gradientTo")}
|
||||
value={gradient.to}
|
||||
disabled={disabled}
|
||||
onChange={(to) =>
|
||||
onChange({
|
||||
...document,
|
||||
backgroundGradient: { ...gradient, to },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.rotation")}
|
||||
value={gradient.angle}
|
||||
min={-360}
|
||||
max={360}
|
||||
disabled={disabled}
|
||||
onChange={(angle) =>
|
||||
onChange({
|
||||
...document,
|
||||
backgroundGradient: { ...gradient, angle },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
["#f4efe4", "#ffdbbc"],
|
||||
["#dbeafe", "#ddd6fe"],
|
||||
["#07130b", "#16a06e"],
|
||||
["#fce7f3", "#fb7185"],
|
||||
["#dcfce7", "#baf264"],
|
||||
["#111827", "#4f46e5"],
|
||||
].map(([from, to]) => (
|
||||
<button
|
||||
key={from}
|
||||
disabled={disabled}
|
||||
className="h-8 rounded-md border border-black/5"
|
||||
aria-label={`${from} ${to}`}
|
||||
style={{
|
||||
background: `linear-gradient(120deg,${from},${to})`,
|
||||
}}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...document,
|
||||
background: from,
|
||||
backgroundGradient: {
|
||||
kind: "linear",
|
||||
from,
|
||||
to,
|
||||
angle: 120,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PosterSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
export function PosterTool({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
text = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
text?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size={text ? "sm" : "icon"}
|
||||
variant={active ? "soft" : "ghost"}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={
|
||||
active ? "text-emerald-700 bg-emerald-50 border-emerald-200" : ""
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{text && <span>{label}</span>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
export function PosterSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3 border-b border-slate-200/60 pb-4 last:border-0">
|
||||
<h3 className="text-xs font-semibold tracking-wide text-slate-500">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function PosterNumber({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = -8192,
|
||||
max = 8192,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(String(Number(value.toFixed(2))));
|
||||
useEffect(() => {
|
||||
setDraft(String(Number(value.toFixed(2))));
|
||||
}, [value]);
|
||||
const apply = () => {
|
||||
const number = Number(draft);
|
||||
if (
|
||||
draft.trim() &&
|
||||
Number.isFinite(number) &&
|
||||
number >= min &&
|
||||
number <= max
|
||||
)
|
||||
onChange(number);
|
||||
else setDraft(String(Number(value.toFixed(2))));
|
||||
};
|
||||
return (
|
||||
<label className="space-y-1 text-[11px] text-slate-500">
|
||||
<span>{label}</span>
|
||||
<Input
|
||||
aria-label={label}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className="h-8 px-2 text-xs"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={apply}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export const POSTER_SWATCHES = [
|
||||
"#07130b",
|
||||
"#ffffff",
|
||||
"#f4efe4",
|
||||
"#16a06e",
|
||||
"#baf264",
|
||||
"#ffe04b",
|
||||
"#ff6b45",
|
||||
"#e63946",
|
||||
"#2563eb",
|
||||
"#7c3aed",
|
||||
"#fb7185",
|
||||
"#14b8a6",
|
||||
];
|
||||
export function PosterColor({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
swatches = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
swatches?: boolean;
|
||||
}) {
|
||||
const hex = /^#[a-f0-9]{6}$/i.test(value) ? value : "#000000";
|
||||
const [draft, setDraft] = useState(hex);
|
||||
useEffect(() => setDraft(hex), [hex]);
|
||||
const apply = () => {
|
||||
if (/^#[a-f0-9]{6}$/i.test(draft)) onChange(draft);
|
||||
else setDraft(hex);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="flex-1 text-slate-500">{label}</span>
|
||||
<input
|
||||
type="color"
|
||||
aria-label={label}
|
||||
value={hex}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-7 w-8 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
/>
|
||||
<Input
|
||||
aria-label={`${label} HEX`}
|
||||
className="h-7 w-24 px-2 font-mono text-xs"
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={apply}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
apply();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{swatches && (
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{POSTER_SWATCHES.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
disabled={disabled}
|
||||
aria-label={`${label} ${color}`}
|
||||
aria-pressed={hex.toLowerCase() === color}
|
||||
onClick={() => onChange(color)}
|
||||
className={`h-6 rounded border border-black/10 ${hex.toLowerCase() === color ? "ring-2 ring-emerald-500 ring-offset-2" : ""}`}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignHorizontalDistributeCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
AlignVerticalDistributeCenter,
|
||||
ArrowDownToLine,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
ArrowUpToLine,
|
||||
Bold,
|
||||
Copy,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
Image,
|
||||
Italic,
|
||||
Lock,
|
||||
Trash2,
|
||||
Underline,
|
||||
Unlock,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PosterElement } from "@edgeever/shared";
|
||||
import { POSTER_FONTS } from "@/lib/poster-design/fonts";
|
||||
import {
|
||||
PosterColor,
|
||||
PosterNumber,
|
||||
PosterSection,
|
||||
PosterTool,
|
||||
} from "./PosterControls";
|
||||
export type PosterAlign =
|
||||
"left" | "center" | "right" | "top" | "middle" | "bottom";
|
||||
export function PosterInspector({
|
||||
elements,
|
||||
disabled,
|
||||
onPatch,
|
||||
onAlign,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onImageFit,
|
||||
zh,
|
||||
}: {
|
||||
elements: PosterElement[];
|
||||
disabled: boolean;
|
||||
onPatch: (patch: Partial<PosterElement>) => void;
|
||||
onAlign: (align: PosterAlign) => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onImageFit: (cover: boolean) => void;
|
||||
zh: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const active = elements.length === 1 ? elements[0] : undefined;
|
||||
const locked = elements.some((element) => element.locked),
|
||||
editable = !disabled && !locked;
|
||||
const alignments = [
|
||||
"left",
|
||||
"center",
|
||||
"right",
|
||||
"top",
|
||||
"middle",
|
||||
"bottom",
|
||||
] as const;
|
||||
const icons = [
|
||||
ArrowLeftToLine,
|
||||
AlignHorizontalDistributeCenter,
|
||||
ArrowRightToLine,
|
||||
ArrowUpToLine,
|
||||
AlignVerticalDistributeCenter,
|
||||
ArrowDownToLine,
|
||||
];
|
||||
if (!elements.length)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-12 text-center text-slate-400">
|
||||
<Image className="h-9 w-9 stroke-1" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
{t("poster.design.selectHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{elements.length > 1
|
||||
? t("poster.design.selectionCount", { count: elements.length })
|
||||
: active?.type === "text"
|
||||
? t("poster.text")
|
||||
: active?.type === "image"
|
||||
? t("poster.image")
|
||||
: t("poster.design.shape")}
|
||||
</h2>
|
||||
<div className="flex gap-0.5">
|
||||
<PosterTool
|
||||
label={t("poster.design.duplicate")}
|
||||
disabled={!editable}
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t("poster.delete")}
|
||||
disabled={!editable}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</PosterTool>
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<Input
|
||||
aria-label={t("poster.layerName")}
|
||||
className="h-8 text-xs"
|
||||
value={active.name}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onPatch({ name: event.target.value.slice(0, 120) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PosterSection title={t("poster.design.position")}>
|
||||
<div className="grid grid-cols-6 gap-0.5">
|
||||
{alignments.map((align, index) => {
|
||||
const Icon = icons[index];
|
||||
return (
|
||||
<PosterTool
|
||||
key={align}
|
||||
label={t(`poster.design.align.${align}`)}
|
||||
disabled={!editable}
|
||||
onClick={() => onAlign(align)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{active && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PosterNumber
|
||||
label="X"
|
||||
value={active.x}
|
||||
disabled={!editable}
|
||||
onChange={(x) => onPatch({ x })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label="Y"
|
||||
value={active.y}
|
||||
disabled={!editable}
|
||||
onChange={(y) => onPatch({ y })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.width")}
|
||||
value={active.width}
|
||||
min={1}
|
||||
disabled={!editable}
|
||||
onChange={(width) => onPatch({ width })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.height")}
|
||||
value={active.height}
|
||||
min={1}
|
||||
disabled={!editable}
|
||||
onChange={(height) => onPatch({ height })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.rotation")}
|
||||
value={active.rotation}
|
||||
min={-360}
|
||||
max={360}
|
||||
disabled={!editable}
|
||||
onChange={(rotation) => onPatch({ rotation })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.opacity")}
|
||||
value={active.opacity * 100}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={!editable}
|
||||
onChange={(opacity) => onPatch({ opacity: opacity / 100 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PosterSection>
|
||||
{active?.type === "text" && (
|
||||
<>
|
||||
<PosterSection title={t("poster.design.typography")}>
|
||||
<Textarea
|
||||
aria-label={t("poster.text")}
|
||||
value={active.text}
|
||||
disabled={!editable}
|
||||
maxLength={8000}
|
||||
className="min-h-20 resize-y text-xs"
|
||||
onChange={(event) =>
|
||||
onPatch({
|
||||
text: event.target.value,
|
||||
sourceText: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("poster.fontFamily")}
|
||||
className="h-9 w-full rounded-md border border-slate-200 bg-card px-2 text-xs"
|
||||
value={active.fontFamily}
|
||||
disabled={!editable}
|
||||
onChange={(event) => onPatch({ fontFamily: event.target.value })}
|
||||
>
|
||||
<option value="sans-serif">{t("poster.sans")}</option>
|
||||
<option value="serif">{t("poster.serif")}</option>
|
||||
<option value="monospace">{t("poster.mono")}</option>
|
||||
{POSTER_FONTS.map((font) => (
|
||||
<option key={font.family} value={font.family}>
|
||||
{zh ? font.zh : font.en}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<PosterNumber
|
||||
label={t("poster.fontSize")}
|
||||
value={active.fontSize}
|
||||
min={4}
|
||||
max={1024}
|
||||
disabled={!editable}
|
||||
onChange={(fontSize) => onPatch({ fontSize })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex pb-0.5">
|
||||
<PosterTool
|
||||
label={t("poster.bold")}
|
||||
active={active.fontWeight === "bold"}
|
||||
disabled={!editable}
|
||||
onClick={() =>
|
||||
onPatch({
|
||||
fontWeight:
|
||||
active.fontWeight === "bold" ? "normal" : "bold",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t("poster.design.italic")}
|
||||
active={active.fontStyle === "italic"}
|
||||
disabled={!editable}
|
||||
onClick={() =>
|
||||
onPatch({
|
||||
fontStyle:
|
||||
active.fontStyle === "italic" ? "normal" : "italic",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t("poster.design.underline")}
|
||||
active={active.underline}
|
||||
disabled={!editable}
|
||||
onClick={() => onPatch({ underline: !active.underline })}
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["left", "center", "right"] as const).map((align, index) => {
|
||||
const Icon = [AlignLeft, AlignCenter, AlignRight][index];
|
||||
return (
|
||||
<PosterTool
|
||||
key={align}
|
||||
label={t(`poster.design.textAlign.${align}`)}
|
||||
active={active.textAlign === align}
|
||||
disabled={!editable}
|
||||
onClick={() => onPatch({ textAlign: align })}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PosterNumber
|
||||
label={t("poster.design.lineHeight")}
|
||||
value={active.lineHeight ?? 1.25}
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.05}
|
||||
disabled={!editable}
|
||||
onChange={(lineHeight) => onPatch({ lineHeight })}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.tracking")}
|
||||
value={active.charSpacing ?? 0}
|
||||
min={-300}
|
||||
max={1000}
|
||||
disabled={!editable}
|
||||
onChange={(charSpacing) => onPatch({ charSpacing })}
|
||||
/>
|
||||
</div>
|
||||
<PosterColor
|
||||
label={t("poster.color")}
|
||||
value={active.fill}
|
||||
disabled={!editable}
|
||||
onChange={(fill) => onPatch({ fill, gradient: undefined })}
|
||||
swatches
|
||||
/>
|
||||
</PosterSection>
|
||||
{active.badge && (
|
||||
<PosterSection title={t("poster.design.badgeBackground")}>
|
||||
<PosterColor
|
||||
label={t("poster.background")}
|
||||
value={active.badge.background}
|
||||
disabled={!editable}
|
||||
onChange={(background) =>
|
||||
onPatch({ badge: { ...active.badge!, background } })
|
||||
}
|
||||
/>
|
||||
</PosterSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{active && active.type !== "text" && active.type !== "image" && (
|
||||
<PosterSection title={t("poster.design.fill")}>
|
||||
<PosterColor
|
||||
label={t("poster.color")}
|
||||
value={active.fill}
|
||||
disabled={!editable}
|
||||
onChange={(fill) => onPatch({ fill, gradient: undefined })}
|
||||
swatches
|
||||
/>
|
||||
{active.type === "rect" && (
|
||||
<PosterNumber
|
||||
label={t("poster.design.radius")}
|
||||
value={active.radius ?? 0}
|
||||
min={0}
|
||||
max={Math.min(active.width, active.height) / 2}
|
||||
disabled={!editable}
|
||||
onChange={(radius) => onPatch({ radius })}
|
||||
/>
|
||||
)}
|
||||
</PosterSection>
|
||||
)}
|
||||
{active?.type === "image" && (
|
||||
<PosterSection title={t("poster.design.imageAdjust")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!editable}
|
||||
onClick={() => onImageFit(false)}
|
||||
>
|
||||
{t("poster.design.fitImage")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!editable}
|
||||
onClick={() => onImageFit(true)}
|
||||
>
|
||||
{t("poster.design.coverImage")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<PosterTool
|
||||
label={t("poster.design.flipHorizontal")}
|
||||
disabled={!editable}
|
||||
onClick={() => onPatch({ flipX: !active.flipX })}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t("poster.design.flipVertical")}
|
||||
disabled={!editable}
|
||||
onClick={() => onPatch({ flipY: !active.flipY })}
|
||||
>
|
||||
<FlipVertical className="h-4 w-4" />
|
||||
</PosterTool>
|
||||
</div>
|
||||
{active.crop && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PosterNumber
|
||||
label={t("poster.design.cropX")}
|
||||
min={0}
|
||||
max={Math.max(0, 100 * (1 - active.crop.width))}
|
||||
value={active.crop.x * 100}
|
||||
onChange={(x) =>
|
||||
onPatch({ crop: { ...active.crop!, x: x / 100 } })
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
<PosterNumber
|
||||
label={t("poster.design.cropY")}
|
||||
min={0}
|
||||
max={Math.max(0, 100 * (1 - active.crop.height))}
|
||||
value={active.crop.y * 100}
|
||||
onChange={(y) =>
|
||||
onPatch({ crop: { ...active.crop!, y: y / 100 } })
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PosterSection>
|
||||
)}
|
||||
{active && active.type !== "image" && (
|
||||
<>
|
||||
<PosterSection title={t("poster.design.outline")}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PosterNumber
|
||||
label={t("poster.design.strokeWidth")}
|
||||
value={active.strokeWidth ?? 0}
|
||||
min={0}
|
||||
max={100}
|
||||
disabled={!editable}
|
||||
onChange={(strokeWidth) =>
|
||||
onPatch({ strokeWidth, stroke: active.stroke ?? "#07130b" })
|
||||
}
|
||||
/>
|
||||
<PosterColor
|
||||
label={t("poster.color")}
|
||||
value={active.stroke ?? "#07130b"}
|
||||
disabled={!editable}
|
||||
onChange={(stroke) =>
|
||||
onPatch({ stroke, strokeWidth: active.strokeWidth || 2 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PosterSection>
|
||||
<PosterSection title={t("poster.design.shadow")}>
|
||||
<label className="flex items-center justify-between text-xs">
|
||||
<span>{t("poster.design.enableShadow")}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(active.shadow)}
|
||||
disabled={!editable}
|
||||
onChange={(event) =>
|
||||
onPatch({
|
||||
shadow: event.target.checked
|
||||
? { color: "#000000", blur: 12, offsetX: 4, offsetY: 6 }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{active.shadow && (
|
||||
<div className="space-y-2">
|
||||
<PosterColor
|
||||
label={t("poster.color")}
|
||||
value={active.shadow.color}
|
||||
disabled={!editable}
|
||||
onChange={(color) =>
|
||||
onPatch({ shadow: { ...active.shadow!, color } })
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<PosterNumber
|
||||
label={t("poster.design.blur")}
|
||||
value={active.shadow.blur}
|
||||
min={0}
|
||||
max={300}
|
||||
disabled={!editable}
|
||||
onChange={(blur) =>
|
||||
onPatch({ shadow: { ...active.shadow!, blur } })
|
||||
}
|
||||
/>
|
||||
<PosterNumber
|
||||
label="X"
|
||||
value={active.shadow.offsetX}
|
||||
min={-300}
|
||||
max={300}
|
||||
disabled={!editable}
|
||||
onChange={(offsetX) =>
|
||||
onPatch({ shadow: { ...active.shadow!, offsetX } })
|
||||
}
|
||||
/>
|
||||
<PosterNumber
|
||||
label="Y"
|
||||
value={active.shadow.offsetY}
|
||||
min={-300}
|
||||
max={300}
|
||||
disabled={!editable}
|
||||
onChange={(offsetY) =>
|
||||
onPatch({ shadow: { ...active.shadow!, offsetY } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PosterSection>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => onPatch({ locked: !locked })}
|
||||
>
|
||||
{locked ? (
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t(locked ? "poster.unlock" : "poster.lock")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Group,
|
||||
Image,
|
||||
Lock,
|
||||
Shapes,
|
||||
Type,
|
||||
Ungroup,
|
||||
Unlock,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PosterElement } from "@edgeever/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PosterTool } from "./PosterControls";
|
||||
export function PosterLayers({
|
||||
elements,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
onChange,
|
||||
onGroup,
|
||||
onMove,
|
||||
}: {
|
||||
elements: PosterElement[];
|
||||
selected: string[];
|
||||
disabled: boolean;
|
||||
onSelect: (id: string, append: boolean) => void;
|
||||
onChange: (id: string, patch: Partial<PosterElement>) => void;
|
||||
onGroup: (ungroup: boolean) => void;
|
||||
onMove: (front: boolean, oneStep: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const active = elements.filter((element) => selected.includes(element.id));
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
{t("poster.design.layerCount", { count: elements.length })}
|
||||
</span>
|
||||
<div className="flex">
|
||||
<PosterTool
|
||||
label={t("poster.front")}
|
||||
disabled={disabled || !active.length}
|
||||
onClick={() => onMove(true, true)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t("poster.backLayer")}
|
||||
disabled={disabled || !active.length}
|
||||
onClick={() => onMove(false, true)}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</PosterTool>
|
||||
</div>
|
||||
</div>
|
||||
{[...elements].reverse().map((element) => {
|
||||
const Icon =
|
||||
element.type === "text"
|
||||
? Type
|
||||
: element.type === "image"
|
||||
? Image
|
||||
: Shapes;
|
||||
const picked = selected.includes(element.id);
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`group flex items-center gap-1 rounded-md border px-1 py-1 ${picked ? "border-emerald-300 bg-emerald-50/70" : "border-transparent hover:bg-slate-50/70"}`}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 p-1.5 text-left"
|
||||
aria-pressed={picked}
|
||||
onClick={(event) =>
|
||||
onSelect(
|
||||
element.id,
|
||||
event.shiftKey || event.metaKey || event.ctrlKey,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-xs ${element.visible === false ? "text-slate-400 line-through" : ""}`}
|
||||
>
|
||||
{element.type === "text"
|
||||
? element.text || t("poster.text")
|
||||
: element.name === "decor" || element.name === "accent"
|
||||
? t("poster.accent")
|
||||
: element.name}
|
||||
</span>
|
||||
{element.groupId && <Group className="h-3 w-3 text-slate-400" />}
|
||||
</button>
|
||||
<PosterTool
|
||||
label={t(
|
||||
element.visible === false
|
||||
? "poster.design.show"
|
||||
: "poster.design.hide",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange(element.id, { visible: element.visible === false })
|
||||
}
|
||||
>
|
||||
{element.visible === false ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3 opacity-40 group-hover:opacity-100" />
|
||||
)}
|
||||
</PosterTool>
|
||||
<PosterTool
|
||||
label={t(element.locked ? "poster.unlock" : "poster.lock")}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(element.id, { locked: !element.locked })}
|
||||
>
|
||||
{element.locked ? (
|
||||
<Lock className="h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 opacity-40 group-hover:opacity-100" />
|
||||
)}
|
||||
</PosterTool>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-slate-200/60 pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || active.length < 2}
|
||||
onClick={() => onGroup(false)}
|
||||
>
|
||||
<Group className="h-3.5 w-3.5" />
|
||||
{t("poster.design.group")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || !active.some((element) => element.groupId)}
|
||||
onClick={() => onGroup(true)}
|
||||
>
|
||||
<Ungroup className="h-3.5 w-3.5" />
|
||||
{t("poster.design.ungroup")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-slate-400">
|
||||
{t("poster.design.multiSelectHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
buildPosterDesign,
|
||||
DESIGN_TEMPLATES,
|
||||
templatesFor,
|
||||
} from "@/lib/poster-design/templates";
|
||||
import { renderPosterThumbnail } from "@/lib/poster-canvas";
|
||||
|
||||
type Template = (typeof DESIGN_TEMPLATES)[number];
|
||||
const Preview = memo(function Preview({
|
||||
template,
|
||||
width,
|
||||
height,
|
||||
zh,
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
template: Template;
|
||||
width: number;
|
||||
height: number;
|
||||
zh: boolean;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const host = useRef<HTMLButtonElement>(null);
|
||||
const [visible, setVisible] = useState(false),
|
||||
[url, setUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "120px" },
|
||||
);
|
||||
if (host.current) observer.observe(host.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false,
|
||||
preview: string | undefined;
|
||||
void buildPosterDesign({
|
||||
id: template.id,
|
||||
title: zh ? "把想法\n变成作品" : "MAKE IDEAS\nWORTH SHARING",
|
||||
subtitle: zh ? "一个好想法,值得被看见。" : "Ideas deserve to be seen.",
|
||||
badge: zh ? "创作笔记" : "CREATIVE NOTES",
|
||||
width,
|
||||
height,
|
||||
zh,
|
||||
})
|
||||
.then(renderPosterThumbnail)
|
||||
.then((next) => {
|
||||
preview = next;
|
||||
if (cancelled) URL.revokeObjectURL(next);
|
||||
else setUrl(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUrl(undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
};
|
||||
}, [visible, width, height, zh, template.id]);
|
||||
return (
|
||||
<button
|
||||
ref={host}
|
||||
disabled={disabled}
|
||||
data-poster-template={template.id}
|
||||
aria-label={zh ? template.zh : template.en}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={`group overflow-hidden rounded-lg border p-1.5 text-left transition disabled:opacity-50 ${selected ? "border-emerald-500 bg-emerald-50/50 ring-1 ring-emerald-500" : "border-slate-200/80 bg-card hover:border-emerald-400"}`}
|
||||
>
|
||||
<div className="relative flex aspect-[3/4] items-center justify-center overflow-hidden rounded bg-slate-100">
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={zh ? template.zh : template.en}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{zh ? "载入版式…" : "Loading layout…"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 truncate px-1 text-[11px] font-medium">
|
||||
{zh ? template.zh : template.en}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
export function PosterTemplatePanel({
|
||||
width,
|
||||
height,
|
||||
platformId,
|
||||
templateId,
|
||||
zh,
|
||||
disabled,
|
||||
onApply,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
platformId?: string;
|
||||
templateId?: string;
|
||||
zh: boolean;
|
||||
disabled: boolean;
|
||||
onApply: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState(""),
|
||||
[recommended, setRecommended] = useState(true);
|
||||
const templates = (
|
||||
recommended ? templatesFor(platformId) : DESIGN_TEMPLATES
|
||||
).filter((template) =>
|
||||
`${template.zh} ${template.en} ${template.zhUse} ${template.enUse}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="sticky -top-0.5 z-10 space-y-2 bg-card py-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold">
|
||||
{t("poster.templates")}{" "}
|
||||
<span className="font-normal text-slate-400">
|
||||
{templates.length}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
className="text-[11px] text-emerald-600"
|
||||
onClick={() => setRecommended(!recommended)}
|
||||
>
|
||||
{t(
|
||||
recommended
|
||||
? "poster.design.allTemplates"
|
||||
: "poster.design.recommended",
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-slate-400" />
|
||||
<Input
|
||||
className="h-8 pl-8 text-xs"
|
||||
value={search}
|
||||
aria-label={t("poster.design.searchTemplates")}
|
||||
placeholder={t("poster.design.searchTemplates")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map((template) => (
|
||||
<Preview
|
||||
key={template.id}
|
||||
template={template}
|
||||
width={width}
|
||||
height={height}
|
||||
zh={zh}
|
||||
selected={template.id === templateId}
|
||||
onSelect={() => onApply(template.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-slate-400">
|
||||
{t("poster.design.templateCredit")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeMemoDocs,
|
||||
getDiagramSummary,
|
||||
getInfographicSummary,
|
||||
getPosterSummary,
|
||||
getTableSummary,
|
||||
getNotebookDescendantIds,
|
||||
resolveMemoContentDoc,
|
||||
@@ -80,6 +81,7 @@ const toSummary = (memo: MemoDetail): MemoSummary => ({
|
||||
excerpt: memo.excerpt,
|
||||
...getDiagramSummary(memo.contentMarkdown),
|
||||
...getInfographicSummary(memo.contentMarkdown),
|
||||
...getPosterSummary(memo.contentMarkdown),
|
||||
...getTableSummary(memo.contentMarkdown),
|
||||
tags: memo.tags,
|
||||
isPinned: memo.isPinned,
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
ActiveSelection,
|
||||
Canvas,
|
||||
StaticCanvas,
|
||||
Textbox,
|
||||
Rect,
|
||||
Ellipse,
|
||||
FabricImage,
|
||||
Gradient,
|
||||
Path,
|
||||
Shadow,
|
||||
Point,
|
||||
util,
|
||||
type FabricObject,
|
||||
} from "fabric";
|
||||
import {
|
||||
PosterDocumentSchema,
|
||||
type PosterDocument,
|
||||
type PosterElement,
|
||||
type PosterPaintGradient,
|
||||
} from "@edgeever/shared";
|
||||
import { BadgeBox } from "./poster-design/vendor/qiaomu/badge";
|
||||
import { loadPosterFont } from "./poster-design/fonts";
|
||||
|
||||
export function makePosterGradient(
|
||||
value: PosterPaintGradient,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const angle = ((value.angle - 90) * Math.PI) / 180,
|
||||
dx = Math.cos(angle),
|
||||
dy = Math.sin(angle);
|
||||
const half = (Math.abs(width * dx) + Math.abs(height * dy)) / 2;
|
||||
const c = value.coords;
|
||||
const coords = c
|
||||
? {
|
||||
x1: c.x1 * width,
|
||||
y1: c.y1 * height,
|
||||
x2: c.x2 * width,
|
||||
y2: c.y2 * height,
|
||||
...(value.kind === "radial"
|
||||
? {
|
||||
r1: (c.r1 ?? 0) * Math.max(width, height),
|
||||
r2: (c.r2 ?? 1) * Math.max(width, height),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: value.kind === "radial"
|
||||
? {
|
||||
x1: width / 2,
|
||||
y1: height / 2,
|
||||
x2: width / 2,
|
||||
y2: height / 2,
|
||||
r1: 0,
|
||||
r2: Math.max(width, height) / 2,
|
||||
}
|
||||
: {
|
||||
x1: width / 2 - dx * half,
|
||||
y1: height / 2 - dy * half,
|
||||
x2: width / 2 + dx * half,
|
||||
y2: height / 2 + dy * half,
|
||||
};
|
||||
const colorStops = value.stops ?? [
|
||||
{ offset: 0, color: value.from },
|
||||
{ offset: 1, color: value.to },
|
||||
];
|
||||
return value.kind === "radial"
|
||||
? new Gradient<"radial">({
|
||||
type: "radial",
|
||||
gradientUnits: "pixels",
|
||||
coords: {
|
||||
...coords,
|
||||
r1: "r1" in coords ? (coords.r1 ?? 0) : 0,
|
||||
r2:
|
||||
"r2" in coords
|
||||
? (coords.r2 ?? Math.max(width, height))
|
||||
: Math.max(width, height),
|
||||
},
|
||||
colorStops,
|
||||
})
|
||||
: new Gradient<"linear">({
|
||||
type: "linear",
|
||||
gradientUnits: "pixels",
|
||||
coords,
|
||||
colorStops,
|
||||
});
|
||||
}
|
||||
export const posterObject = async (
|
||||
element: PosterElement,
|
||||
readResource: (id: string) => Promise<Blob>,
|
||||
): Promise<FabricObject> => {
|
||||
const options = {
|
||||
left: element.x,
|
||||
top: element.y,
|
||||
angle: element.rotation,
|
||||
opacity: element.opacity,
|
||||
strokeWidth: element.strokeWidth ?? 0,
|
||||
stroke: element.stroke,
|
||||
strokeUniform: true,
|
||||
paintFirst: "stroke" as const,
|
||||
originX: "left" as const,
|
||||
originY: "top" as const,
|
||||
flipX: element.flipX ?? false,
|
||||
flipY: element.flipY ?? false,
|
||||
visible: element.visible ?? true,
|
||||
shadow: element.shadow ? new Shadow(element.shadow) : undefined,
|
||||
selectable: !element.locked,
|
||||
evented: !element.locked,
|
||||
borderColor: "#16a06e",
|
||||
cornerColor: "#16a06e",
|
||||
cornerStrokeColor: "#ffffff",
|
||||
cornerSize: 9,
|
||||
cornerStyle: "circle" as const,
|
||||
transparentCorners: false,
|
||||
};
|
||||
const fill = element.gradient
|
||||
? makePosterGradient(element.gradient, element.width, element.height)
|
||||
: "fill" in element
|
||||
? element.fill
|
||||
: undefined;
|
||||
if (element.type === "text") {
|
||||
await loadPosterFont(element.fontFamily);
|
||||
const textOptions = {
|
||||
...options,
|
||||
width: element.width,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle ?? "normal",
|
||||
underline: element.underline ?? false,
|
||||
lineHeight: element.lineHeight ?? 1.25,
|
||||
charSpacing: element.charSpacing ?? 0,
|
||||
textAlign: element.textAlign,
|
||||
fill,
|
||||
splitByGrapheme: true,
|
||||
};
|
||||
if (element.badge) {
|
||||
const badge = new BadgeBox(element.text, {
|
||||
...textOptions,
|
||||
badgeBg: element.badge.background,
|
||||
padX: element.badge.padX,
|
||||
padY: element.badge.padY,
|
||||
radius: element.badge.radius,
|
||||
});
|
||||
badge.editable = true;
|
||||
return badge;
|
||||
}
|
||||
return new Textbox(element.text, textOptions);
|
||||
}
|
||||
if (element.type === "rect")
|
||||
return new Rect({
|
||||
...options,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
fill,
|
||||
rx: element.radius ?? 0,
|
||||
ry: element.radius ?? 0,
|
||||
});
|
||||
if (element.type === "ellipse")
|
||||
return new Ellipse({
|
||||
...options,
|
||||
rx: element.width / 2,
|
||||
ry: element.height / 2,
|
||||
fill,
|
||||
});
|
||||
if (element.type === "path") {
|
||||
const path = new Path(element.path, { ...options, fill });
|
||||
path.set({
|
||||
scaleX: element.width / Math.max(1, path.width),
|
||||
scaleY: element.height / Math.max(1, path.height),
|
||||
});
|
||||
return path;
|
||||
}
|
||||
if (element.type !== "image") throw new Error("Unsupported poster element");
|
||||
const blob = await readResource(element.resourceId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await FabricImage.fromURL(url);
|
||||
const crop = element.crop ?? { x: 0, y: 0, width: 1, height: 1 };
|
||||
const width = image.width * crop.width,
|
||||
height = image.height * crop.height;
|
||||
image.set({
|
||||
...options,
|
||||
cropX: image.width * crop.x,
|
||||
cropY: image.height * crop.y,
|
||||
width,
|
||||
height,
|
||||
scaleX: element.width / width,
|
||||
scaleY: element.height / height,
|
||||
});
|
||||
return image;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
export const posterElementFromObject = (
|
||||
element: PosterElement,
|
||||
object: FabricObject,
|
||||
): PosterElement => {
|
||||
const matrix = object.calcTransformMatrix();
|
||||
const transform = util.qrDecompose(matrix);
|
||||
const radians = (transform.angle * Math.PI) / 180;
|
||||
const offsetX =
|
||||
-(object.width * Math.abs(transform.scaleX) + object.strokeWidth) / 2;
|
||||
const offsetY =
|
||||
-(object.height * Math.abs(transform.scaleY) + object.strokeWidth) / 2;
|
||||
const point = {
|
||||
x: matrix[4] + offsetX * Math.cos(radians) - offsetY * Math.sin(radians),
|
||||
y: matrix[5] + offsetX * Math.sin(radians) + offsetY * Math.cos(radians),
|
||||
};
|
||||
return {
|
||||
...element,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation: transform.angle % 360,
|
||||
width: Math.max(1, Math.abs(object.width * transform.scaleX)),
|
||||
height: Math.max(1, Math.abs(object.height * transform.scaleY)),
|
||||
flipX: false,
|
||||
flipY: transform.scaleY < 0,
|
||||
...(element.type === "text" && object instanceof Textbox
|
||||
? {
|
||||
text: object.text,
|
||||
sourceText:
|
||||
object.text === element.text ? element.sourceText : object.text,
|
||||
fontSize: object.fontSize * Math.abs(transform.scaleY),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
export const renderPosterBlob = async (
|
||||
document: PosterDocument,
|
||||
readResource: (id: string) => Promise<Blob>,
|
||||
format: "png" | "jpeg" | "webp" = "png",
|
||||
preview = false,
|
||||
scale = 1,
|
||||
quality = 0.92,
|
||||
) => {
|
||||
PosterDocumentSchema.parse(document);
|
||||
const canvas = new StaticCanvas(undefined, {
|
||||
width: document.width,
|
||||
height: document.height,
|
||||
backgroundColor: document.backgroundGradient
|
||||
? makePosterGradient(
|
||||
document.backgroundGradient,
|
||||
document.width,
|
||||
document.height,
|
||||
)
|
||||
: document.background,
|
||||
enableRetinaScaling: false,
|
||||
});
|
||||
try {
|
||||
canvas.add(
|
||||
...(await Promise.all(
|
||||
document.elements.map((element) => posterObject(element, readResource)),
|
||||
)),
|
||||
);
|
||||
canvas.renderAll();
|
||||
const output = canvas.toCanvasElement(
|
||||
preview
|
||||
? Math.min(1, 480 / document.width)
|
||||
: Math.min(scale, 8192 / Math.max(document.width, document.height)),
|
||||
);
|
||||
return await new Promise<Blob>((resolve, reject) =>
|
||||
output.toBlob(
|
||||
(blob) =>
|
||||
blob ? resolve(blob) : reject(new Error("Image export failed")),
|
||||
`image/${format}`,
|
||||
quality,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await canvas.dispose();
|
||||
}
|
||||
};
|
||||
export const renderPosterThumbnail = async (
|
||||
document: PosterDocument,
|
||||
): Promise<string> => {
|
||||
const blob = await renderPosterBlob(
|
||||
document,
|
||||
async () => {
|
||||
throw new Error("Template thumbnails cannot access resources");
|
||||
},
|
||||
"png",
|
||||
true,
|
||||
);
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
export { Canvas, ActiveSelection };
|
||||
|
||||
/** AI colours must be checked against the actual card underneath, not just the outer canvas background. */
|
||||
export async function ensurePosterContrast(
|
||||
document: PosterDocument,
|
||||
readResource: (id: string) => Promise<Blob>,
|
||||
): Promise<PosterDocument> {
|
||||
const { contrast } = await import("./poster-design/vendor/qiaomu/quality");
|
||||
const background = new StaticCanvas(undefined, {
|
||||
width: document.width,
|
||||
height: document.height,
|
||||
enableRetinaScaling: false,
|
||||
backgroundColor: document.backgroundGradient
|
||||
? makePosterGradient(
|
||||
document.backgroundGradient,
|
||||
document.width,
|
||||
document.height,
|
||||
)
|
||||
: document.background,
|
||||
});
|
||||
try {
|
||||
const objects = await Promise.all(
|
||||
document.elements.map((element) => posterObject(element, readResource)),
|
||||
);
|
||||
const elements: PosterElement[] = [];
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const element = document.elements[i],
|
||||
object = objects[i];
|
||||
if (
|
||||
element.type === "text" &&
|
||||
["title", "subtitle", "badge"].includes(element.role ?? "") &&
|
||||
!element.gradient &&
|
||||
element.visible !== false
|
||||
) {
|
||||
background.renderAll();
|
||||
const colors: string[] = [];
|
||||
if (element.badge) colors.push(element.badge.background);
|
||||
else {
|
||||
const bounds = object.getBoundingRect();
|
||||
for (const fraction of [0.25, 0.5, 0.75]) {
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
document.width - 1,
|
||||
Math.round(bounds.left + bounds.width * fraction),
|
||||
),
|
||||
),
|
||||
y = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
document.height - 1,
|
||||
Math.round(bounds.top + bounds.height / 2),
|
||||
),
|
||||
);
|
||||
const pixel = background.getContext().getImageData(x, y, 1, 1).data;
|
||||
colors.push(
|
||||
`#${[pixel[0], pixel[1], pixel[2]].map((value) => value.toString(16).padStart(2, "0")).join("")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const average = (fill: string) =>
|
||||
colors.reduce(
|
||||
(sum, color) => sum + (contrast(fill, color) ?? 21),
|
||||
0,
|
||||
) / colors.length;
|
||||
const best =
|
||||
average("#111111") >= average("#ffffff") ? "#111111" : "#ffffff";
|
||||
const outlined =
|
||||
element.stroke &&
|
||||
(element.strokeWidth ?? 0) >= 2 &&
|
||||
average(element.stroke) >= 3;
|
||||
if (average(element.fill) < 3 && !outlined) {
|
||||
elements.push({ ...element, fill: best });
|
||||
object.set("fill", best);
|
||||
} else elements.push(element);
|
||||
} else elements.push(element);
|
||||
background.add(object);
|
||||
}
|
||||
return { ...document, elements };
|
||||
} finally {
|
||||
await background.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export const POSTER_FONTS = [
|
||||
{
|
||||
family: "思源宋体",
|
||||
file: "notoserif",
|
||||
zh: "思源宋体 · 正文",
|
||||
en: "Source Han Serif",
|
||||
},
|
||||
{ family: "Anton", file: "anton", zh: "Anton · 冲击", en: "Anton" },
|
||||
{
|
||||
family: "抖音美好体",
|
||||
file: "douyin",
|
||||
zh: "抖音美好体",
|
||||
en: "Douyin Display",
|
||||
},
|
||||
{
|
||||
family: "马善政楷书",
|
||||
file: "mashan",
|
||||
zh: "马善政楷书",
|
||||
en: "Ma Shan Zheng",
|
||||
},
|
||||
{
|
||||
family: "江城圆体",
|
||||
file: "jiangcheng",
|
||||
zh: "江城圆体",
|
||||
en: "Jiangcheng Rounded",
|
||||
},
|
||||
{
|
||||
family: "站酷庆科黄油体",
|
||||
file: "huangyou",
|
||||
zh: "站酷庆科黄油体",
|
||||
en: "ZCOOL QingKe HuangYou",
|
||||
},
|
||||
{ family: "未来荧黑", file: "glow", zh: "未来荧黑", en: "Glow Sans" },
|
||||
{ family: "朱雀仿宋", file: "zhuque", zh: "朱雀仿宋", en: "Zhuque FangSong" },
|
||||
{
|
||||
family: "站酷快乐体",
|
||||
file: "kuaile",
|
||||
zh: "站酷快乐体",
|
||||
en: "ZCOOL KuaiLe",
|
||||
},
|
||||
|
||||
{
|
||||
family: "思源黑体",
|
||||
file: "notosans",
|
||||
zh: "思源黑体 · 正文",
|
||||
en: "Source Han Sans",
|
||||
},
|
||||
{
|
||||
family: "思源黑体 Heavy",
|
||||
file: "notosans-heavy",
|
||||
zh: "思源黑体 · 特重",
|
||||
en: "Source Han Sans Heavy",
|
||||
},
|
||||
{
|
||||
family: "思源宋体 Heavy",
|
||||
file: "notoserif-heavy",
|
||||
zh: "思源宋体 · 特重",
|
||||
en: "Source Han Serif Heavy",
|
||||
},
|
||||
{ family: "得意黑", file: "smiley", zh: "得意黑 · 标题", en: "Smiley Sans" },
|
||||
{
|
||||
family: "霞鹜文楷",
|
||||
file: "wenkai",
|
||||
zh: "霞鹜文楷 · 手写",
|
||||
en: "LXGW WenKai",
|
||||
},
|
||||
{ family: "Inter", file: "inter", zh: "Inter · 无衬线", en: "Inter" },
|
||||
{
|
||||
family: "DM Serif Display",
|
||||
file: "dmserif",
|
||||
zh: "DM Serif · 衬线",
|
||||
en: "DM Serif Display",
|
||||
},
|
||||
{ family: "Bebas Neue", file: "bebas", zh: "Bebas · 窄体", en: "Bebas Neue" },
|
||||
{
|
||||
family: "Space Grotesk",
|
||||
file: "spacegrotesk",
|
||||
zh: "Space Grotesk · 科技",
|
||||
en: "Space Grotesk",
|
||||
},
|
||||
{
|
||||
family: "JetBrains Mono",
|
||||
file: "jetbrains",
|
||||
zh: "JetBrains · 等宽",
|
||||
en: "JetBrains Mono",
|
||||
},
|
||||
] as const;
|
||||
const loads = new Map<string, Promise<void>>();
|
||||
export function loadPosterFont(family: string) {
|
||||
const font = POSTER_FONTS.find((entry) => entry.family === family);
|
||||
if (!font) return Promise.resolve();
|
||||
if (!loads.has(family)) {
|
||||
const face = new FontFace(
|
||||
family,
|
||||
`url("/poster-fonts/${font.file}.${font.file === "douyin" ? "ttf" : "woff2"}")`,
|
||||
);
|
||||
const promise = face
|
||||
.load()
|
||||
.then((loaded) => {
|
||||
window.document.fonts.add(loaded);
|
||||
})
|
||||
.catch((error) => {
|
||||
loads.delete(family);
|
||||
throw error;
|
||||
});
|
||||
loads.set(family, promise);
|
||||
}
|
||||
return loads.get(family)!;
|
||||
}
|
||||
export const loadDesignFonts = (zh: boolean) =>
|
||||
Promise.all(
|
||||
(zh
|
||||
? [
|
||||
"思源黑体",
|
||||
"思源黑体 Heavy",
|
||||
"思源宋体",
|
||||
"思源宋体 Heavy",
|
||||
"得意黑",
|
||||
"霞鹜文楷",
|
||||
"抖音美好体",
|
||||
"马善政楷书",
|
||||
"江城圆体",
|
||||
"站酷庆科黄油体",
|
||||
"未来荧黑",
|
||||
"朱雀仿宋",
|
||||
"站酷快乐体",
|
||||
"DM Serif Display",
|
||||
"Anton",
|
||||
]
|
||||
: ["Inter", "DM Serif Display", "Bebas Neue", "Space Grotesk"]
|
||||
).map(loadPosterFont),
|
||||
);
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
Circle,
|
||||
Color,
|
||||
Ellipse,
|
||||
FabricObject,
|
||||
Gradient,
|
||||
Line,
|
||||
Path,
|
||||
Pattern,
|
||||
Polygon,
|
||||
Rect,
|
||||
Textbox,
|
||||
Triangle,
|
||||
} from "fabric";
|
||||
import {
|
||||
PosterDocumentSchema,
|
||||
type PosterDocument,
|
||||
type PosterElement,
|
||||
type PosterPaintGradient,
|
||||
} from "@edgeever/shared";
|
||||
import { TEMPLATES, sourceOf, templatesFor } from "./vendor/qiaomu/templates";
|
||||
import { BadgeBox } from "./vendor/qiaomu/badge";
|
||||
import { resolvePair, usePairing } from "./vendor/qiaomu/typeset";
|
||||
import { POSTER_FONTS, loadDesignFonts } from "./fonts";
|
||||
export { PLATFORMS, platformFor } from "./vendor/qiaomu/platforms";
|
||||
export const DESIGN_TEMPLATES = TEMPLATES;
|
||||
export { templatesFor };
|
||||
const hex = (color: string | undefined) =>
|
||||
`#${new Color(color || "#000000").toHex()}`;
|
||||
const paint = (object: FabricObject) =>
|
||||
typeof object.fill === "string" && object.fill
|
||||
? object.fill
|
||||
: object.fill instanceof Gradient
|
||||
? hex(object.fill.colorStops[0]?.color)
|
||||
: "rgba(0,0,0,0)";
|
||||
export function gradientFromFabric(
|
||||
value: Gradient<"linear" | "radial">,
|
||||
width: number,
|
||||
height: number,
|
||||
): PosterPaintGradient {
|
||||
const c = value.coords as {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
r1?: number;
|
||||
r2?: number;
|
||||
};
|
||||
return {
|
||||
kind: value.type,
|
||||
from: hex(value.colorStops[0]?.color),
|
||||
to: hex(value.colorStops.at(-1)?.color),
|
||||
angle: 90,
|
||||
stops: value.colorStops.map((stop) => ({
|
||||
offset: stop.offset,
|
||||
color: stop.color,
|
||||
opacity: (stop as typeof stop & { opacity?: number }).opacity,
|
||||
})),
|
||||
coords: {
|
||||
x1: c.x1 / width,
|
||||
y1: c.y1 / height,
|
||||
x2: c.x2 / width,
|
||||
y2: c.y2 / height,
|
||||
...(value.type === "radial"
|
||||
? {
|
||||
r1: (c.r1 ?? 0) / Math.max(width, height),
|
||||
r2: (c.r2 ?? 0) / Math.max(width, height),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
export function posterPrimitiveFromObject(
|
||||
object: FabricObject,
|
||||
index: number,
|
||||
): PosterElement | null {
|
||||
if (object.fill instanceof Pattern) return null; // Film grain is a generated raster tile, not editable vector source.
|
||||
const roleValue = (object as FabricObject & { qcRole?: string }).qcRole;
|
||||
const role =
|
||||
roleValue === "title" || roleValue === "subtitle" || roleValue === "badge"
|
||||
? roleValue
|
||||
: "decor";
|
||||
const pos = object.getPointByOrigin("left", "top");
|
||||
const options = {
|
||||
id: `design_${index}`,
|
||||
name: role,
|
||||
role,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: Math.max(1, object.width * Math.abs(object.scaleX)),
|
||||
height: Math.max(1, object.height * Math.abs(object.scaleY)),
|
||||
rotation: object.angle % 360,
|
||||
opacity: object.opacity,
|
||||
locked: false,
|
||||
...(object.stroke && typeof object.stroke === "string"
|
||||
? { stroke: object.stroke, strokeWidth: object.strokeWidth }
|
||||
: {}),
|
||||
...(object.shadow
|
||||
? {
|
||||
shadow: {
|
||||
color: object.shadow.color,
|
||||
blur: object.shadow.blur,
|
||||
offsetX: object.shadow.offsetX,
|
||||
offsetY: object.shadow.offsetY,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(object.fill instanceof Gradient
|
||||
? {
|
||||
gradient: gradientFromFabric(
|
||||
object.fill,
|
||||
Math.max(1, object.width),
|
||||
Math.max(1, object.height),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
} satisfies Partial<PosterElement>;
|
||||
if (object instanceof Textbox)
|
||||
return {
|
||||
...options,
|
||||
type: "text",
|
||||
text: object.text,
|
||||
sourceText: sourceOf(object),
|
||||
fill: paint(object),
|
||||
fontSize: object.fontSize * Math.abs(object.scaleY),
|
||||
fontFamily: object.fontFamily,
|
||||
fontWeight: ["bold", "700", "800", "900"].includes(
|
||||
String(object.fontWeight),
|
||||
)
|
||||
? "bold"
|
||||
: "normal",
|
||||
fontStyle: object.fontStyle === "italic" ? "italic" : "normal",
|
||||
textAlign:
|
||||
object.textAlign === "center" || object.textAlign === "right"
|
||||
? object.textAlign
|
||||
: "left",
|
||||
lineHeight: object.lineHeight,
|
||||
charSpacing: object.charSpacing,
|
||||
...(object instanceof BadgeBox
|
||||
? {
|
||||
badge: {
|
||||
background: hex(object.badgeBg),
|
||||
padX: object.padX,
|
||||
padY: object.padY,
|
||||
radius: object.radius,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (object instanceof Rect)
|
||||
return {
|
||||
...options,
|
||||
type: "rect",
|
||||
fill: paint(object),
|
||||
radius: object.rx * Math.abs(object.scaleX),
|
||||
};
|
||||
if (object instanceof Circle || object instanceof Ellipse)
|
||||
return { ...options, type: "ellipse", fill: paint(object) };
|
||||
let path: string | undefined;
|
||||
if (object instanceof Path)
|
||||
path = object.path.map((command) => command.join(" ")).join(" ");
|
||||
if (object instanceof Line)
|
||||
path = `M ${object.x1} ${object.y1} L ${object.x2} ${object.y2}`;
|
||||
if (object instanceof Polygon)
|
||||
path =
|
||||
object.points
|
||||
.map((point, i) => `${i ? "L" : "M"} ${point.x} ${point.y}`)
|
||||
.join(" ") + " Z";
|
||||
if (object instanceof Triangle)
|
||||
path = `M ${object.width / 2} 0 L ${object.width} ${object.height} L 0 ${object.height} Z`;
|
||||
if (!path) throw new Error(`Unsupported design primitive: ${object.type}`);
|
||||
return { ...options, type: "path", path, fill: paint(object) };
|
||||
}
|
||||
export function posterCopy(document: PosterDocument) {
|
||||
if (document.copy) return document.copy;
|
||||
const text = document.elements.filter((element) => element.type === "text");
|
||||
const title =
|
||||
text.find(
|
||||
(element) => element.role === "title" || element.id === "heading",
|
||||
) ?? text[0];
|
||||
const subtitle =
|
||||
text.find(
|
||||
(element) => element.role === "subtitle" || element.id === "subtitle",
|
||||
) ?? text[1];
|
||||
const badge = text.find((element) => element.role === "badge");
|
||||
return {
|
||||
title: title?.sourceText ?? title?.text ?? "",
|
||||
subtitle: subtitle?.sourceText ?? subtitle?.text ?? "",
|
||||
badge: badge?.sourceText ?? badge?.text ?? "",
|
||||
};
|
||||
}
|
||||
export async function buildPosterDesign(input: {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge?: string;
|
||||
points?: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
zh: boolean;
|
||||
palette?: { bg?: string; ink?: string; accent?: string };
|
||||
}): Promise<PosterDocument> {
|
||||
await loadDesignFonts(input.zh);
|
||||
const template =
|
||||
TEMPLATES.find((entry) => entry.id === input.id) ?? TEMPLATES[0]!;
|
||||
usePairing(
|
||||
input.zh
|
||||
? resolvePair(template.id, (family) =>
|
||||
POSTER_FONTS.some((font) => font.family === family),
|
||||
)
|
||||
: {
|
||||
title:
|
||||
template.id === "newspaper" || template.id === "folio"
|
||||
? "DM Serif Display"
|
||||
: "Inter",
|
||||
body: "Inter",
|
||||
titleBold: true,
|
||||
missing: [],
|
||||
},
|
||||
);
|
||||
try {
|
||||
const built = template.build({ ...input });
|
||||
const elements = built.objects
|
||||
.map(posterPrimitiveFromObject)
|
||||
.filter((value): value is PosterElement => Boolean(value));
|
||||
const background =
|
||||
built.background.kind === "solid"
|
||||
? built.background.color
|
||||
: built.background.from;
|
||||
const result = {
|
||||
copy: {
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
badge: input.badge ?? "",
|
||||
},
|
||||
schemaVersion: 1,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
templateId: template.id,
|
||||
background: hex(background),
|
||||
...(built.background.kind === "linear"
|
||||
? {
|
||||
backgroundGradient: {
|
||||
kind: "linear",
|
||||
from: hex(built.background.from),
|
||||
to: hex(built.background.to),
|
||||
angle: built.background.angle,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
elements,
|
||||
};
|
||||
return PosterDocumentSchema.parse(result);
|
||||
} finally {
|
||||
usePairing(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-2026 向阳乔木 (joeseesun)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,6 @@
|
||||
Qiaomu Design 0.2.4 template and typesetting primitives.
|
||||
|
||||
Source: https://github.com/joeseesun/qiaomu-cover-design/tree/0.2.4
|
||||
|
||||
Copyright (c) 2025–2026 向阳乔木 (joeseesun), MIT. See LICENSE.
|
||||
The files are pure design primitives; Obsidian runtime, credentials and storage are not included.
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A text label with a filled pill behind it, drawn by hand so the words sit exactly in the middle.
|
||||
* Fabric's own text background fills the whole line box, and the extra leading ends up on one side, so CJK labels look
|
||||
* dropped. Here the pill is sized from the measured glyph height of a reference character and the baseline is placed so
|
||||
* its cap height is centred, with equal padding left and right. Serialises as an ordinary object and reloads by type.
|
||||
*/
|
||||
import { classRegistry, getEnv, Textbox, TextboxProps } from 'fabric';
|
||||
|
||||
export interface BadgeProps { badgeBg: string; padX: number; padY: number; radius: number }
|
||||
let ctx2d: CanvasRenderingContext2D | null | undefined;
|
||||
function measureCtx(): CanvasRenderingContext2D | null {
|
||||
if (ctx2d === undefined) { try { ctx2d = getEnv().document?.createElement('canvas').getContext('2d') ?? null; } catch { ctx2d = null; } }
|
||||
return ctx2d;
|
||||
}
|
||||
interface Metrics { width: number; ascent: number; descent: number }
|
||||
/** Width of the text and the ink extent of a reference glyph pair (cap/CJK height), in px. */
|
||||
export function measureLabel(text: string, size: number, weight: string, family: string): Metrics {
|
||||
const c = measureCtx(); if (!c) return { width: [...text].length * size * 0.8, ascent: size * 0.8, descent: size * 0.04 };
|
||||
c.font = `${weight} ${size}px ${family}`; const width = c.measureText(text).width; const ref = c.measureText('H国');
|
||||
return { width, ascent: ref.actualBoundingBoxAscent || size * 0.8, descent: Math.max(0, ref.actualBoundingBoxDescent || size * 0.04) };
|
||||
}
|
||||
|
||||
export class BadgeBox extends Textbox {
|
||||
static type = 'BadgeBox';
|
||||
static customProperties = ['badgeBg', 'padX', 'padY', 'radius'];
|
||||
declare badgeBg: string; declare padX: number; declare padY: number; declare radius: number;
|
||||
constructor(text: string, options?: Partial<TextboxProps> & Partial<BadgeProps>) { super(text, { badgeBg: '#111111', padX: 0.6, padY: 0.34, radius: 0.5, editable: false, lineHeight: 1, ...options } as Partial<TextboxProps>); }
|
||||
private metrics(): Metrics { return measureLabel(this.text.trim(), this.fontSize, String(this.fontWeight), this.fontFamily); }
|
||||
/** Size comes from the measured label, not from Fabric's line layout. */
|
||||
initDimensions(): void {
|
||||
const m = this.metrics(); const px = this.padX * this.fontSize, py = this.padY * this.fontSize;
|
||||
this.width = m.width + px * 2; this.height = m.ascent + m.descent + py * 2;
|
||||
this.textLines = [this.text]; this._textLines = [[...this.text]]; this.dirty = true;
|
||||
}
|
||||
/** The pill is the visible extent. Fabric's own line cache is never filled here, so its version would throw. */
|
||||
getLineWidth(): number { return this.width; }
|
||||
_render(c: CanvasRenderingContext2D): void {
|
||||
const w = this.width, h = this.height; const m = this.metrics(); const r = Math.min(h / 2, this.radius * h);
|
||||
c.save(); c.fillStyle = this.badgeBg; c.beginPath();
|
||||
c.moveTo(-w / 2 + r, -h / 2); c.arcTo(w / 2, -h / 2, w / 2, h / 2, r); c.arcTo(w / 2, h / 2, -w / 2, h / 2, r); c.arcTo(-w / 2, h / 2, -w / 2, -h / 2, r); c.arcTo(-w / 2, -h / 2, w / 2, -h / 2, r); c.closePath(); c.fill();
|
||||
c.fillStyle = typeof this.fill === 'string' ? this.fill : '#ffffff'; c.font = `${this.fontWeight} ${this.fontSize}px ${this.fontFamily}`; c.textAlign = 'center'; c.textBaseline = 'alphabetic';
|
||||
c.fillText(this.text.trim(), 0, (m.ascent - m.descent) / 2); c.restore();
|
||||
}
|
||||
}
|
||||
classRegistry.setClass(BadgeBox, 'BadgeBox');
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Layouts drawn from the formats that dominate the Gaoding (稿定设计) recommended feed for Xiaohongshu and WeChat, kept to the
|
||||
* ones that live on type and flat shapes alone (no stock photo, no illustration), redrawn to the design rules: one focal
|
||||
* headline, flat colour, no outlined type, no starbursts. Helpers come from templates.ts and are only used inside build().
|
||||
*/
|
||||
import { Circle, FabricObject, Line, Path, Polygon, Rect, Shadow } from 'fabric';
|
||||
import { grain } from './kit';
|
||||
import { badge, dot, FABRIC_LINE, fitTitle, hasCjk, keep, lineW, metrics, meta, pill, rect, solid, sub, Template, textbox, textHeight, wrapLines, palette } from './templates';
|
||||
|
||||
const XHS = ['xhs', 'xhs-square', 'portrait', 'square']; const VIDEO = ['youtube', 'bilibili', 'bilibili-43', 'bilibili-hd', 'wide']; const BANNER = ['wechat', 'x'];
|
||||
/** Items for a checklist row: the points, or a subtitle written as a list. */
|
||||
const items = (points: string[] | undefined, subtitle: string, max: number): string[] => {
|
||||
const xs = (points?.length ? points : subtitle.split(/[\n,,、;;||]/)).map(s => s.trim()).filter(Boolean);
|
||||
return xs.length >= 2 ? xs.slice(0, max) : [];
|
||||
};
|
||||
/** Tags a shape as part of the layout's signature, so the clean-up that runs when a subject picture arrives keeps it. */
|
||||
const mark = <T extends FabricObject>(o: T, kind: 'mark' | 'mark-title' = 'mark'): T => Object.assign(o, { qcKind: kind });
|
||||
/** A hand-drawn arrow: one curved stroke and an open head, round caps. From (x0,y0) bending to (x1,y1). */
|
||||
function arrow(x0: number, y0: number, x1: number, y1: number, bend: number, color: string, weight: number): Path {
|
||||
const mx = (x0 + x1) / 2 + bend, my = (y0 + y1) / 2 - bend * 0.4;
|
||||
const ang = Math.atan2(y1 - my, x1 - mx); const head = weight * 3.4;
|
||||
const a = (k: number): string => `${(x1 - Math.cos(ang + k) * head).toFixed(1)} ${(y1 - Math.sin(ang + k) * head).toFixed(1)}`;
|
||||
return mark(new Path(`M${x0} ${y0} Q${mx} ${my} ${x1} ${y1} M${a(0.55)} L${x1} ${y1} L${a(-0.55)}`, { fill: '', stroke: color, strokeWidth: weight, strokeLineCap: 'round', strokeLineJoin: 'round', objectCaching: false }));
|
||||
}
|
||||
/** A dry-brush block: a rectangle with ragged, slightly wavy long edges, for a highlighter stroke made by a brush. */
|
||||
function brushBlock(x: number, y: number, w: number, h: number, color: string, seed = 3): Path {
|
||||
const n = 9; let s = seed * 7919 + 17; const rnd = (): number => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
let d = `M${x} ${y + h * 0.12}`;
|
||||
for (let k = 1; k <= n; k++) d += ` L${(x + (w * k) / n).toFixed(1)} ${(y + h * (0.04 + rnd() * 0.16)).toFixed(1)}`;
|
||||
d += ` L${(x + w + h * 0.12).toFixed(1)} ${(y + h * 0.55).toFixed(1)}`;
|
||||
for (let k = n; k >= 0; k--) d += ` L${(x + (w * k) / n).toFixed(1)} ${(y + h * (0.84 + rnd() * 0.14)).toFixed(1)}`;
|
||||
return mark(new Path(`${d} Z`, { fill: color, stroke: '', angle: -1.2 }), 'mark-title');
|
||||
}
|
||||
|
||||
export const GAODING: Template[] = [
|
||||
{
|
||||
id: 'regeng', zh: '热梗卡', en: 'Daily drop', fit: [...XHS, 'wechat'], zhUse: '黑色话题条(#标签#)+ 三层错位描边卡片里的粗黑大字 + 左下“No.期号”;每日热梗、资讯速递、系列笔记,小红书辨识度很高', enUse: 'A black hashtag bar, a heavy headline in a stack of three offset outlined cards and an issue number; daily drops, news and series notes',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#f2f2ef', bg2: '#f2f2ef', ink: '#111111', sub: '#4b4b4b', accent: '#e8432b', accentInk: '#ffffff' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.25; const bar = Math.max(56, 88 * u); const sw = Math.max(4, 6 * u); const off = Math.max(8, 14 * u);
|
||||
const tag = (i.badge?.trim() || (i.zh ? '每日新鲜事' : 'daily')).replace(/^#|#$/g, '');
|
||||
const bx = m, by = bar + m * 0.9; const bw = side ? w * 0.6 - m : w - m * 2 - off * 2; const bh = side ? h - by - m - off * 2 : h * 0.5;
|
||||
const pad = Math.max(m * 0.6, 34 * u); const tw = bw - pad * 2;
|
||||
const size = fitTitle(i.title, tw, bh - pad * 2, 200 * u, 36, 1.08); const th = textHeight(i.title, tw, size, 1.08);
|
||||
const card = (k: number): Rect => rect(bx + off * k, by + off * k, bw, bh, k ? p.bg : '#ffffff', { stroke: p.ink, strokeWidth: sw });
|
||||
const no = `No.${String(Number(/\d+/.exec(i.badge ?? '')?.[0] ?? 1)).padStart(3, '0')}`; const ns = Math.max(40, Math.min(110 * u, h * 0.07));
|
||||
const nx = side ? bx + bw + off * 2 + m : bx; const ny = side ? by : by + bh + off * 2 + m * 0.8; const nw = side ? w - nx - m : w - m * 2;
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
grain(w, h, 0.05, 151),
|
||||
rect(0, 0, w, bar, p.ink),
|
||||
textbox(`#${tag}#`, m, (bar - bar * 0.42 * 1.13) / 2, w * 0.6, { qcRole: 'badge', fontSize: bar * 0.42, fill: '#ffffff', fontWeight: 'bold', lineHeight: 1 }),
|
||||
// The record switch lives in the bar: a white track with the red "live" light on.
|
||||
rect(w - m - bar * 0.95, bar * 0.3, bar * 0.95, bar * 0.4, 'rgba(0,0,0,0)', { rx: bar * 0.2, ry: bar * 0.2, stroke: '#ffffff', strokeWidth: Math.max(2, 3 * u) }),
|
||||
dot(w - m - bar * 0.25, bar * 0.5, bar * 0.13, p.accent),
|
||||
card(2), card(1), card(0),
|
||||
textbox(i.title, bx + pad, by + (bh - th) / 2, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.08 }),
|
||||
textbox(no, nx, ny, nw, { fontSize: ns, fill: p.ink, fontFamily: 'Anton', lineHeight: 1, charSpacing: 20 }),
|
||||
rect(nx, ny + ns * 1.22, Math.max(40, ns * 0.9), Math.max(4, 6 * u), p.accent),
|
||||
...sub(i, nx, ny + ns * 1.22 + 24 * u, nw, Math.max(24, size * 0.3), p.sub, { fontWeight: 'bold' }),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'interview', zh: '黑黄箭头', en: 'Black & yellow', fit: [...XHS, ...VIDEO, 'vertical'], zhUse: '黑底 + 黄色超粗标题 + 一条手绘大箭头指向标题 + 白框英文标签 + 角落笑脸;面试、考试、干货清单,强对比又不土', enUse: 'Black, a heavy yellow headline, one big hand-drawn arrow pointing at it, a boxed label and a corner smiley; interviews, exams and checklists',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#0f0f0f', bg2: '#0f0f0f', ink: '#ffe14a', sub: '#ffffff', accent: '#ffe14a', accentInk: '#0f0f0f' });
|
||||
const { u, m, w, h } = metrics(i); const side = w / h > 1.25; const x0 = m * 1.1; const tw = side ? w * 0.62 - x0 : w - x0 * 2;
|
||||
const label = (i.badge?.trim() || 'INTERVIEW').toUpperCase(); const ls = Math.max(30, Math.min(side ? h * 0.1 : w * 0.085, (tw * 0.9) / Math.max(4, [...label].length * (hasCjk(label) ? 1 : 0.62))));
|
||||
const lh = ls * 1.5; const ly = m * 0.9; const lw = Math.min(tw, lineW(label, ls, 'bold', 0) + ls * 1.2);
|
||||
const size = fitTitle(i.title, tw, h * (side ? 0.5 : 0.36), 230 * u, 40, 1.04); const th = textHeight(i.title, tw, size, 1.04);
|
||||
const subS = Math.max(24, size * 0.28); const subH = i.subtitle.trim() ? textHeight(i.subtitle, tw, subS, 1.25) + 28 * u : 0;
|
||||
const ty = side ? Math.max(ly + lh + m * 0.6, (h - th - subH) / 2) : Math.max(ly + lh + h * 0.16, h * 0.42 - th / 2);
|
||||
const aw = Math.max(10, 24 * u);
|
||||
const arrowTo = { x: side ? x0 + tw + m * 0.2 : x0 + tw * 0.62, y: ty - (side ? -th * 0.2 : m * 0.25) };
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
grain(w, h, 0.07, 157),
|
||||
rect(x0, ly, lw, lh, 'rgba(0,0,0,0)', { stroke: '#ffffff', strokeWidth: Math.max(3, 5 * u) }),
|
||||
textbox(label, x0, ly + (lh - ls * 1.13) / 2, lw, { qcRole: 'badge', fontSize: ls, fill: '#ffffff', fontWeight: 'bold', textAlign: 'center', lineHeight: 1 }),
|
||||
arrow(side ? w - m * 1.2 : w - m * 1.1, side ? h * 0.18 : ly + lh * 0.6, arrowTo.x, arrowTo.y, side ? -w * 0.06 : -w * 0.12, p.accent, aw),
|
||||
textbox(i.title, x0, ty, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.04 }),
|
||||
...sub(i, x0, ty + th + 28 * u, tw, subS, p.sub, { fontWeight: 'bold' }),
|
||||
textbox(':)', w - m - 120 * u, h - m - 70 * u, 120 * u, { fontSize: Math.max(40, 64 * u), fill: '#ffffff', fontWeight: 'bold', textAlign: 'right', lineHeight: 1 }),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'brush', zh: '笔刷收藏', en: 'Brush highlight', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '白底粗黑标题 + 最后一行压一道绿色笔刷 + 歪贴的“建议收藏!”标签 + 底部 ✓ 清单(points);副业、避坑、干货,最像小红书爆款笔记', enUse: 'Heavy black type on white, a green brush stroke under the last line, a tilted “save this” tag and a check row from the points; tips and side hustles',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffffff', bg2: '#ffffff', ink: '#111111', sub: '#4b4b4b', accent: '#3ddc84', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.82 : 1); const lh = 1.12;
|
||||
const size = fitTitle(i.title, tw, h * (wide ? 0.5 : 0.48), 240 * u, 40, lh); const lines = wrapLines(i.title, tw, size, 'bold', -18); const step = size * lh * FABRIC_LINE; const th = lines.length * step;
|
||||
const checks = items(i.points, i.subtitle, 3); const cs = Math.max(24, size * 0.28);
|
||||
// The check row wraps instead of running off the page; its height is known before the block is centred.
|
||||
const cw = (t: string): number => cs * 2.2 + lineW(t, cs, 'bold', 0); const flow: { t: string; x: number; y: number }[] = []; { let fx = 0, fy = 0; for (const t of checks) { if (fx && fx + cw(t) > tw) { fx = 0; fy += cs * 1.7; } flow.push({ t, x: fx, y: fy }); fx += cw(t); } }
|
||||
const rowH = checks.length ? (flow[flow.length - 1]!.y + cs * 1.4) : 0;
|
||||
const subS = Math.max(24, size * 0.28); const subH = !checks.length && i.subtitle.trim() ? textHeight(i.subtitle, tw, subS, 1.25) + 30 * u : 0;
|
||||
const tagS = Math.max(22, size * 0.24); const tagH = tagS * 1.3 * 1.7 + 40 * u;
|
||||
const ty = (h - tagH - th - subH - rowH - (rowH ? 40 * u : 0)) / 2 + tagH; const last = lines[lines.length - 1] ?? '';
|
||||
const ray = (k: number): Line => { const a = (-150 - k * 28) * Math.PI / 180; const cx = x0 + tagS * 0.2, cy = ty - tagH * 0.55; const r0 = tagS * 0.9, r1 = tagS * 1.6; return mark(new Line([cx + Math.cos(a) * r0, cy + Math.sin(a) * r0, cx + Math.cos(a) * r1, cy + Math.sin(a) * r1], { stroke: p.ink, strokeWidth: Math.max(3, 5 * u), strokeLineCap: 'round' }), 'mark-title'); };
|
||||
const rowY = ty + th + 40 * u;
|
||||
const row = flow.flatMap(({ t, x, y }) => { const cx = x0 + x, cy = rowY + y; return [dot(cx + cs * 0.5, cy + cs * 0.62, cs * 0.5, p.accent), textbox('✓', cx + cs * 0.1, cy + cs * 0.06, cs * 0.8, { fontSize: cs * 0.9, fill: p.ink, fontWeight: 'bold', textAlign: 'center', lineHeight: 1 }), textbox(t, cx + cs * 1.3, cy, lineW(t, cs, 'bold', 0) + cs, { fontSize: cs, fill: p.ink, fontWeight: 'bold', lineHeight: 1.1 })]; });
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
grain(w, h, 0.04, 163),
|
||||
brushBlock(x0 - size * 0.1, ty + (lines.length - 1) * step + size * 0.22, Math.min(w - x0, lineW(last, size) + size * 0.25), size * 0.95, p.accent, lines.length),
|
||||
ray(0), ray(1), ray(2),
|
||||
pill(i.badge?.trim() || (i.zh ? '建议收藏!' : 'Save this!'), x0 + tagS * 0.4, ty - tagH, p.accent, p.accentInk, tagS * 1.3, { qcRole: 'badge', angle: -8, radius: 0.3 }),
|
||||
textbox(i.title, x0, ty, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: lh, charSpacing: -18 }),
|
||||
...(checks.length ? row : sub(i, x0, ty + th + 30 * u, tw, subS, p.sub, { fontWeight: 'bold' })),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'calendar', zh: '日历页', en: 'Calendar page', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '纯蓝底 + 一张带装订环的白色日历页 + 页里的粗黑大字;每日一句、打卡、待办、节日,干净有仪式感', enUse: 'Flat blue with a white calendar page and binding rings, a heavy headline on the page; daily lines, check-ins and to-dos',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#2f6bff', bg2: '#2f6bff', ink: '#111111', sub: '#555555', accent: '#2f6bff', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const cw = wide ? w * 0.7 : w - m * 2.2; const ch = wide ? h - m * 2.4 : h * 0.66; const cx = (w - cw) / 2; const cy = (h - ch) / 2 + m * 0.3;
|
||||
const r = Math.max(10, 16 * u); const pad = Math.max(m * 0.8, 44 * u); const tw = cw - pad * 2; const holes = Math.max(6, Math.round(cw / (r * 5)));
|
||||
const size = fitTitle(i.title, tw, ch * 0.5, 220 * u, 36, 1.08); const th = textHeight(i.title, tw, size, 1.08);
|
||||
const subS = Math.max(24, size * 0.3); const subH = i.subtitle.trim() ? textHeight(i.subtitle, tw, subS, 1.25) + 30 * u : 0; const head = r * 4;
|
||||
const ty = cy + head + (ch - head - th - subH) / 2;
|
||||
const rings = Array.from({ length: holes }, (_, k) => { const x = cx + (cw / holes) * (k + 0.5); return [mark(dot(x, cy + r * 1.6, r * 0.55, p.bg)), mark(new Rect({ left: x - r * 0.28, top: cy - r * 1.1, width: r * 0.56, height: r * 2.8, rx: r * 0.28, ry: r * 0.28, fill: '#1b1b1b', originX: 'left', originY: 'top' }))]; }).flat();
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
grain(w, h, 0.06, 167),
|
||||
rect(cx + 14 * u, cy + 18 * u, cw, ch, '#dfe6ff', { rx: 18 * u, ry: 18 * u }),
|
||||
rect(cx, cy, cw, ch, '#ffffff', { rx: 18 * u, ry: 18 * u, shadow: new Shadow({ color: 'rgba(0,20,90,0.25)', blur: 40 * u, offsetX: 0, offsetY: 16 * u }) }),
|
||||
...rings,
|
||||
meta((i.badge?.trim() || (i.zh ? '今日' : 'TODAY')).toUpperCase(), cx + pad, cy + head * 0.75, tw * 0.6, Math.max(16, 22 * u), p.accent, { qcRole: 'badge' }),
|
||||
textbox(i.title, cx + pad, ty, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.08, textAlign: 'center' }),
|
||||
...sub(i, cx + pad, ty + th + 30 * u, tw, subS, p.sub, { textAlign: 'center' }),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'frame', zh: '框线大字', en: 'Framed type', fit: [...XHS, ...BANNER, ...VIDEO], zhUse: '纯色底 + 一圈细内框 + 撑满框的超大标题 + 一角出血的太阳圆;招聘、开学、通知、活动,最简单也最醒目', enUse: 'Flat colour, a thin inset frame, a headline that fills it and a sun disc bleeding off a corner; hiring, launches and notices',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#22b573', bg2: '#22b573', ink: '#0f2a1d', sub: '#0f2a1d', accent: '#f4e04d', accentInk: '#0f2a1d' });
|
||||
const { u, m, w, h, wide } = metrics(i); const f = m * 0.55; const pad = m * 0.9; const tw = w - (f + pad) * 2;
|
||||
// The subtitle rides right under the headline: the foot of the frame is left to the sun and to platform UI (9:16 captions).
|
||||
const subS = Math.max(24, 32 * u); const sw = tw * 0.8; const subH = i.subtitle.trim() ? textHeight(i.subtitle, sw, subS, 1.25) + 30 * u : 0; const bs = Math.max(22, 34 * u); const bH = i.badge?.trim() ? bs * 1.3 * 1.8 + 16 * u : 0;
|
||||
// On a banner (wider than 2.2:1) the copy hangs from the top: the lower left is where X puts the avatar.
|
||||
const banner = w / h > 2.2; const room = h - (f + pad) * 2 - bH; const size = fitTitle(i.title, tw, (room - subH) * (banner ? 0.62 : wide ? 0.86 : 0.58), 420 * u, 44, 1.02); const th = textHeight(i.title, tw, size, 1.02);
|
||||
const ty = f + pad + bH + (banner ? 0 : wide ? (room - subH - th) / 2 : room * 0.04); const R = Math.min(w, h) * 0.34;
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
new Circle({ left: w - R * 1.45, top: h - R * 1.25, radius: R, fill: p.accent, opacity: 0.85, originX: 'left', originY: 'top' }),
|
||||
grain(w, h, 0.08, 173),
|
||||
// qcRole marks the border as the page's structure, so platform avoidance moves the words inside it and leaves it whole.
|
||||
rect(f, f, w - f * 2, h - f * 2, 'rgba(0,0,0,0)', { stroke: p.ink, strokeWidth: Math.max(2, 3 * u), qcRole: 'frame' }),
|
||||
...badge(i.badge, f + pad, f + pad * 0.7, { ...p, accent: p.ink, accentInk: p.bg }, bs),
|
||||
textbox(i.title, f + pad, ty, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.02 }),
|
||||
...sub(i, f + pad, ty + th + 30 * u, sw, subS, p.sub, { fontWeight: 'bold' }),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kicker', zh: '粗细引题', en: 'Kicker headline', fit: [...XHS, 'wechat', ...VIDEO], zhUse: '浅灰底:副标题当细字引题写在上面,标题粗黑放大 + 双线胶囊标签 + 底部“右滑查看 ››››”;经验、问答、职场,信息感强', enUse: 'Light grey: the subtitle as a thin kicker above a heavy headline, a double-ring pill tag and a “swipe” cue; experience posts, Q&A, work',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ececea', bg2: '#ececea', ink: '#111111', sub: '#2b2b2b', accent: '#111111', accentInk: '#ffffff' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.72 : 1);
|
||||
const size = fitTitle(i.title, tw, h * (wide ? 0.42 : 0.32), 200 * u, 40, 1.08); const th = textHeight(i.title, tw, size, 1.08);
|
||||
const ks = Math.max(26, size * 0.46); const kick = i.subtitle.trim(); const kh = kick ? textHeight(kick, tw, ks, 1.2) + size * 0.18 : 0;
|
||||
const label = i.badge?.trim() ?? ''; const tagS = Math.max(20, size * 0.2); const gap = 8 * u;
|
||||
const rw = label ? lineW(label, tagS, 'bold', 0) + tagS * 1.6 : 0; const rh = tagS * 2; const tagH = label ? rh + gap * 2 + 36 * u : 0; const foot = Math.max(22, 30 * u);
|
||||
const top = (h - tagH - kh - th) / 2 + tagH; const R = Math.min(w, h) * 0.42; const ry = top - tagH;
|
||||
const ring = (k: number): Rect => rect(x0 + gap * (1 - k), ry + gap * (1 - k), rw + gap * 2 * k, rh + gap * 2 * k, 'rgba(0,0,0,0)', { rx: (rh + gap * 2 * k) / 2, ry: (rh + gap * 2 * k) / 2, stroke: p.ink, strokeWidth: Math.max(1.5, (k ? 2.5 : 2) * u) });
|
||||
const tag = label ? textbox(label, x0 + gap, ry + gap + (rh - tagS * 1.13) / 2, rw, { qcRole: 'badge', fontSize: tagS, fill: p.ink, fontWeight: 'bold', textAlign: 'center', lineHeight: 1 }) : undefined;
|
||||
const rings = label ? [ring(1), ring(0)] : [];
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
new Circle({ left: w - R * 1.25, top: h - R * 1.5, radius: R, fill: 'rgba(0,0,0,0)', stroke: 'rgba(0,0,0,0.06)', strokeWidth: R * 0.32, originX: 'left', originY: 'top' }),
|
||||
grain(w, h, 0.06, 179), ...rings, tag,
|
||||
...(kick ? [textbox(kick, x0, top, tw, { qcRole: 'subtitle', fontSize: ks, fill: p.sub, lineHeight: 1.2 })] : []),
|
||||
textbox(i.title, x0, top + kh, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.08 }),
|
||||
textbox(i.zh ? '右滑查看 ››››' : 'Swipe ››››', x0, h - m - foot * 1.4, w * 0.5, { fontSize: foot, fill: p.ink, fontWeight: 'bold', lineHeight: 1 }),
|
||||
) };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'corner', zh: '斜角色块', en: 'Corner wedge', fit: [...XHS, ...VIDEO, 'wechat'], zhUse: '白底 + 右下一大块斜切薄荷色 + 左上粗黑大字 + 黑色胶囊副标题 + 一条弯箭头;计划、清单、自律、学习,清爽有节奏', enUse: 'White with a big mint wedge from the lower right, a heavy headline top left, a black pill subtitle and a curved arrow; plans, lists and study',
|
||||
build(i) {
|
||||
const p = palette(i, { bg: '#ffffff', bg2: '#ffffff', ink: '#111111', sub: '#ffffff', accent: '#7ee0c3', accentInk: '#111111' });
|
||||
const { u, m, w, h, wide } = metrics(i); const x0 = m * 1.1; const tw = (w - x0 * 2) * (wide ? 0.6 : 0.86);
|
||||
const size = fitTitle(i.title, tw, h * (wide ? 0.5 : 0.34), 220 * u, 40, 1.08); const th = textHeight(i.title, tw, size, 1.08); const ty = m * 1.2 + (i.badge?.trim() ? Math.max(16, 20 * u) * 2.2 : 0);
|
||||
const subS = Math.max(22, size * 0.24);
|
||||
return { background: solid(p.bg), objects: keep<FabricObject>(
|
||||
new Polygon([{ x: w, y: h * (wide ? 0.05 : 0.3) }, { x: w, y: h }, { x: w * (wide ? 0.45 : 0.08), y: h }], { fill: p.accent, originX: 'left', originY: 'top' }),
|
||||
grain(w, h, 0.05, 181),
|
||||
i.badge?.trim() ? meta(i.badge.trim().toUpperCase(), x0, m * 1.1, w * 0.6, Math.max(16, 20 * u), p.ink, { qcRole: 'badge' }) : undefined,
|
||||
textbox(i.title, x0, ty, tw, { qcRole: 'title', fontSize: size, fill: p.ink, fontWeight: 'bold', lineHeight: 1.08 }),
|
||||
...(i.subtitle.trim() ? [pill(i.subtitle, x0, ty + th + 30 * u, p.ink, p.sub, subS, { qcRole: 'subtitle', radius: 0.5 })] : []),
|
||||
arrow(w * (wide ? 0.72 : 0.62), h - m * 1.6, w - m * 1.2, h - m * (wide ? 3.2 : 4.2), -w * 0.04, p.ink, Math.max(6, 12 * u)),
|
||||
) };
|
||||
},
|
||||
},
|
||||
];
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Texture and craft primitives for templates: grain, paper, tape, stickers, tickers, soft light, seals. They are plain
|
||||
* Fabric objects built synchronously, so a template stays a pure function, every piece stays editable, and a thumbnail
|
||||
* renders exactly like the real cover. Nothing here loads files or touches the network.
|
||||
*/
|
||||
import { Circle, FabricObject, getEnv, Gradient, Line, Path, Pattern, Polygon, Rect, Shadow, Textbox } from 'fabric';
|
||||
import { BadgeBox } from './badge';
|
||||
|
||||
type Role = FabricObject & { qcRole?: string };
|
||||
const tag = <T extends FabricObject>(o: T, role: string): T => { (o as Role).qcRole = role; return o; };
|
||||
|
||||
/** A tile of film grain as a repeating pattern over the whole artboard. Undefined where no canvas exists (unit tests). */
|
||||
export function grain(w: number, h: number, opacity = 0.08, seed = 7): Rect | undefined {
|
||||
try {
|
||||
const doc = getEnv().document; if (!doc) return undefined;
|
||||
const cv = doc.createElement('canvas'); cv.width = cv.height = 128; const ctx = cv.getContext('2d'); if (!ctx) return undefined;
|
||||
const img = ctx.createImageData(128, 128); let x = seed * 9301 + 49297;
|
||||
for (let i = 0; i < img.data.length; i += 4) { x = (x * 9301 + 49297) % 233280; const v = (x / 233280) * 255; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
|
||||
ctx.putImageData(img, 0, 0);
|
||||
return tag(new Rect({ left: 0, top: 0, width: w, height: h, originX: 'left', originY: 'top', fill: new Pattern({ source: cv, repeat: 'repeat' }), opacity, selectable: true, globalCompositeOperation: 'overlay' }), 'grain');
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A sheet of paper: slightly rotated, with a soft contact shadow. */
|
||||
export function paper(left: number, top: number, w: number, h: number, fill: string, angle = 0, u = 1, extra: Record<string, unknown> = {}): Rect {
|
||||
return tag(new Rect({ left, top, width: w, height: h, fill, angle, originX: 'left', originY: 'top', shadow: new Shadow({ color: 'rgba(40,25,10,0.28)', blur: 22 * u, offsetX: 0, offsetY: 8 * u }), ...extra }), 'paper');
|
||||
}
|
||||
/** Translucent masking tape. */
|
||||
export function tape(left: number, top: number, w: number, h: number, angle: number, fill = 'rgba(255,224,120,0.78)'): Rect { return tag(new Rect({ left, top, width: w, height: h, fill, angle, originX: 'left', originY: 'top', opacity: 0.92 }), 'tape'); }
|
||||
|
||||
/** An organic blob (one path, scaled and rotated). */
|
||||
export function blob(cx: number, cy: number, size: number, fill: string, opacity = 1, angle = 0): Path {
|
||||
const p = new Path('M155 38c24 22 38 56 24 86s-52 56-88 50-62-38-60-72 22-58 56-70 44-14 68 6z', { fill, opacity, originX: 'center', originY: 'center', left: cx, top: cy, angle });
|
||||
const k = size / 200; p.set({ scaleX: k, scaleY: k }); return p;
|
||||
}
|
||||
export function sparkle(cx: number, cy: number, size: number, fill: string, angle = 0): Path {
|
||||
const p = new Path('M60 4C64 40 80 56 116 60 80 64 64 80 60 116 56 80 40 64 4 60 40 56 56 40 60 4z', { fill, originX: 'center', originY: 'center', left: cx, top: cy, angle }); const k = size / 120; p.set({ scaleX: k, scaleY: k }); return p;
|
||||
}
|
||||
/** A starburst: the classic price-tag shape. */
|
||||
export function burst(cx: number, cy: number, r: number, fill: string, points = 14, angle = 0): Polygon {
|
||||
const pts: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < points * 2; i++) { const rr = i % 2 ? r * 0.66 : r; const a = (i / (points * 2)) * Math.PI * 2; pts.push({ x: Math.cos(a) * rr, y: Math.sin(a) * rr }); }
|
||||
return new Polygon(pts, { fill, originX: 'center', originY: 'center', left: cx, top: cy, angle });
|
||||
}
|
||||
/** Soft light: a radial gradient that fades to nothing, so there is never a visible edge. */
|
||||
export function glow(cx: number, cy: number, r: number, color: string, opacity = 0.6): Circle {
|
||||
return tag(new Circle({ left: cx - r, top: cy - r, radius: r, originX: 'left', originY: 'top', opacity, fill: new Gradient({ type: 'radial', coords: { x1: r, y1: r, r1: 0, x2: r, y2: r, r2: r }, colorStops: [{ offset: 0, color }, { offset: 0.55, color: `${color}55` }, { offset: 1, color: `${color}00` }] }) }), 'glow');
|
||||
}
|
||||
/** A fine rule. */
|
||||
export function rule(x: number, y: number, len: number, color: string, weight = 2, vertical = false, opacity = 1): Line {
|
||||
return new Line(vertical ? [0, 0, 0, len] : [0, 0, len, 0], { left: x, top: y, stroke: color, strokeWidth: weight, opacity, originX: 'left', originY: 'top', strokeLineCap: 'butt' });
|
||||
}
|
||||
/** Faded giant lettering behind the content: outline only, or a quiet fill. */
|
||||
export function ghost(text: string, left: number, top: number, size: number, color: string, opacity = 0.12, outline = false): Textbox {
|
||||
return tag(new Textbox(text, { left, top, width: Math.max(size * text.length * 0.8, size), originX: 'left', originY: 'top', fontSize: size, fontWeight: 'bold', lineHeight: 0.9, fill: outline ? 'rgba(0,0,0,0)' : color, stroke: outline ? color : undefined, strokeWidth: outline ? Math.max(2, size * 0.012) : 0, opacity, selectable: true, splitByGrapheme: false }), 'ghost');
|
||||
}
|
||||
/** A repeating strip of words on a band, rotated: the "now playing" ticker. */
|
||||
export function ticker(w: number, y: number, h: number, word: string, size: number, fg: string, bg: string, angle = 0): FabricObject[] {
|
||||
const unit = `${word} ✦ `; const reps = Math.ceil((w * 1.6) / Math.max(size * unit.length * 0.62, 1)) + 1;
|
||||
const band = new Rect({ left: -w * 0.1, top: y, width: w * 1.2, height: h, fill: bg, originX: 'left', originY: 'top', angle });
|
||||
const text = new Textbox(unit.repeat(reps), { left: -w * 0.08, top: y + (h - size * 1.2) / 2, width: w * 3, fontSize: size, fontWeight: 'bold', fill: fg, originX: 'left', originY: 'top', splitByGrapheme: false, angle, lineHeight: 1.2 });
|
||||
const rad = (angle * Math.PI) / 180; const off = { x: -(h - size * 1.2) / 2 * Math.sin(rad), y: ((h - size * 1.2) / 2) * (Math.cos(rad) - 1) }; text.set({ left: (text.left ?? 0) + off.x, top: (text.top ?? 0) + off.y });
|
||||
return [tag(band, 'ticker'), tag(text, 'ticker')];
|
||||
}
|
||||
/** A round sticker with a word on it. Longer words get smaller, so four characters still sit inside the disc. */
|
||||
export function stickerDot(cx: number, cy: number, r: number, fill: string, label: string, ink: string, angle = 0, ring?: string): FabricObject[] {
|
||||
const dot = new Circle({ left: cx, top: cy, radius: r, fill, stroke: ring, strokeWidth: ring ? r * 0.08 : 0, originX: 'center', originY: 'center', angle, shadow: new Shadow({ color: 'rgba(0,0,0,0.22)', blur: r * 0.3, offsetX: 0, offsetY: r * 0.1 }) });
|
||||
const t = new BadgeBox(label, { fontSize: r * Math.min(0.62, 1.5 / Math.max(1, [...label].length * (/[\u3400-\u9fff]/.test(label) ? 1 : 0.6))), fontWeight: 'bold', fill: ink, badgeBg: 'rgba(0,0,0,0)', padX: 0.2, padY: 0.2, originX: 'center', originY: 'center', left: cx, top: cy, angle, qcRole: 'badge' } as ConstructorParameters<typeof BadgeBox>[1]);
|
||||
return [dot, t];
|
||||
}
|
||||
/** A red seal chop: a rounded square with one character reversed out. */
|
||||
export function seal(left: number, top: number, size: number, char: string, color = '#c0392b'): FabricObject[] {
|
||||
const sq = new Rect({ left, top, width: size, height: size, rx: size * 0.1, ry: size * 0.1, fill: color, originX: 'left', originY: 'top', angle: -3, opacity: 0.92 });
|
||||
const t = new BadgeBox(char.slice(0, 2), { fontSize: size * 0.56, fontWeight: 'bold', fill: '#f6efe0', badgeBg: 'rgba(0,0,0,0)', padX: 0.1, padY: 0.1, originX: 'center', originY: 'center', left: left + size / 2, top: top + size / 2, angle: -3, fontFamily: 'serif' } as ConstructorParameters<typeof BadgeBox>[1]);
|
||||
return [sq, t];
|
||||
}
|
||||
|
||||
/* ---------- print-poster system: the small marks that make a layout read as designed ---------- */
|
||||
/** Corner crop marks inside the artboard (two short strokes per corner). */
|
||||
export function cropMarks(w: number, h: number, inset: number, len: number, color: string, weight = 2): FabricObject[] {
|
||||
const L = (x: number, y: number, dx: number, dy: number): Line => new Line([0, 0, dx * len, dy * len], { left: Math.min(x, x + dx * len), top: Math.min(y, y + dy * len), stroke: color, strokeWidth: weight, originX: 'left', originY: 'top' });
|
||||
const out: FabricObject[] = [];
|
||||
for (const [cx, cy, sx, sy] of [[inset, inset, 1, 1], [w - inset, inset, -1, 1], [inset, h - inset, 1, -1], [w - inset, h - inset, -1, -1]] as const) { out.push(L(cx, cy, sx, 0)); out.push(L(cx, cy, 0, sy)); }
|
||||
return out;
|
||||
}
|
||||
/** A registration target: ring with a crosshair. */
|
||||
export function regMark(cx: number, cy: number, r: number, color: string, weight = 2): FabricObject[] {
|
||||
return [
|
||||
new Circle({ left: cx, top: cy, radius: r, fill: 'rgba(0,0,0,0)', stroke: color, strokeWidth: weight, originX: 'center', originY: 'center' }),
|
||||
new Line([0, 0, r * 2.8, 0], { left: cx - r * 1.4, top: cy, stroke: color, strokeWidth: weight, originX: 'left', originY: 'top' }),
|
||||
new Line([0, 0, 0, r * 2.8], { left: cx, top: cy - r * 1.4, stroke: color, strokeWidth: weight, originX: 'left', originY: 'top' }),
|
||||
];
|
||||
}
|
||||
/** Text that runs along an edge, rotated a quarter turn (up reads bottom-to-top). The box spans x..x+size and y..y+len. */
|
||||
export function sideText(text: string, x: number, y: number, len: number, size: number, color: string, dir: 'up' | 'down' = 'up', opacity = 1): Textbox {
|
||||
const t = new Textbox(text, { left: dir === 'up' ? x : x + size, top: dir === 'up' ? y + len : y, width: len, fontSize: size, fill: color, originX: 'left', originY: 'top', angle: dir === 'up' ? -90 : 90, textAlign: 'center', charSpacing: 320, fontWeight: 'bold', splitByGrapheme: false, lineHeight: 1, opacity });
|
||||
return t;
|
||||
}
|
||||
/** A small block of tracked-out caption lines, like the credits in a poster's corner. */
|
||||
export function metaBlock(lines: string[], left: number, top: number, width: number, size: number, color: string, align: 'left' | 'right' = 'left'): Textbox {
|
||||
return new Textbox(lines.join('\n'), { left, top, width, fontSize: size, fill: color, originX: 'left', originY: 'top', fontWeight: 'bold', charSpacing: 160, lineHeight: 1.55, textAlign: align, splitByGrapheme: false });
|
||||
}
|
||||
/** A barcode-like strip of bars. */
|
||||
export function barcode(left: number, top: number, w: number, h: number, color: string, seed = 5): FabricObject[] {
|
||||
const out: FabricObject[] = []; let x = left; let s = seed * 7919 + 13;
|
||||
while (x < left + w) { s = (s * 9301 + 49297) % 233280; const bw = 2 + Math.floor((s / 233280) * 7); if ((s >> 3) % 3) out.push(new Rect({ left: x, top, width: bw, height: h, fill: color, originX: 'left', originY: 'top' })); x += bw + 2 + ((s >> 5) % 4); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- comic / thumbnail devices ---------- */
|
||||
/** Alternating rays fanning out from a point, as one path (a sunburst or manga speed lines). */
|
||||
export function rays(cx: number, cy: number, reach: number, n: number, fill: string, opacity = 0.1, rot = 0): Path {
|
||||
let d = ''; for (let i = 0; i < n; i++) { const a0 = rot + (i / n) * Math.PI * 2, a1 = rot + ((i + 0.5) / n) * Math.PI * 2; d += `M${cx} ${cy}L${(cx + Math.cos(a0) * reach).toFixed(1)} ${(cy + Math.sin(a0) * reach).toFixed(1)}L${(cx + Math.cos(a1) * reach).toFixed(1)} ${(cy + Math.sin(a1) * reach).toFixed(1)}Z`; }
|
||||
return tag(new Path(d, { fill, opacity, selectable: true }), 'rays');
|
||||
}
|
||||
/** A rounded-top window (arch), the shape behind a portrait in editorial layouts. */
|
||||
export function arch(left: number, top: number, w: number, h: number, fill: string, stroke?: string, strokeWidth = 0): Path {
|
||||
const r = w / 2; return new Path(`M0 ${h}L0 ${r}A${r} ${r} 0 0 1 ${w} ${r}L${w} ${h}Z`, { left, top, fill, stroke, strokeWidth, originX: 'left', originY: 'top' });
|
||||
}
|
||||
/** A grid of dots, optionally fading toward one corner (halftone). */
|
||||
export function dotGrid(left: number, top: number, cols: number, rows: number, gap: number, r: number, fill: string, opacity = 0.2, fade = true): Circle[] {
|
||||
const out: Circle[] = []; for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) { const k = fade ? 1 - (x / cols + y / rows) / 2.2 : 1; if (k > 0.12) out.push(new Circle({ left: left + x * gap, top: top + y * gap, radius: Math.max(1, r * k), fill, opacity, originX: 'center', originY: 'center' })); }
|
||||
return out;
|
||||
}
|
||||
/** A comic caption box: white card, thick ink border and a hard shadow. */
|
||||
export function captionBox(left: number, top: number, w: number, h: number, u: number, fill = '#ffffff', ink = '#111111', angle = 0): Rect {
|
||||
return new Rect({ left, top, width: w, height: h, fill, stroke: ink, strokeWidth: Math.max(4, 7 * u), angle, originX: 'left', originY: 'top', shadow: new Shadow({ color: ink, blur: 0, offsetX: 9 * u, offsetY: 9 * u }) });
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export type Background = { kind: 'solid'; color: string } | { kind: 'linear'; from: string; to: string; angle: number };
|
||||
export type ExportFormat = 'png' | 'jpeg' | 'webp';
|
||||
export type Destination = 'folder' | 'note' | 'system';
|
||||
export interface ExportPrefs {
|
||||
format: ExportFormat; scale: number; quality: number; destination: Destination;
|
||||
folder: string; systemDir: string; filename: string; insert: boolean; cover: boolean; copy: boolean; fitLimit: boolean;
|
||||
}
|
||||
export const EXPORT_DEFAULTS: ExportPrefs = {
|
||||
format: 'jpeg', scale: 1, quality: 0.92, destination: 'folder', folder: '', systemDir: '', filename: '{name}-{platform}', insert: false, cover: false, copy: false, fitLimit: true,
|
||||
};
|
||||
export interface Design {
|
||||
format: 'qiaomu-cover-design'; schema: 1; width: number; height: number; source?: string;
|
||||
/** Platform preset id. Informational: width/height remain the source of truth. */
|
||||
platform?: string; bg?: Background; export?: Partial<ExportPrefs>;
|
||||
/** Id of the template this cover was laid out from. Lets a platform switch re-lay it out instead of just scaling it. */
|
||||
template?: string;
|
||||
/** The palette the cover's layers follow (bg, bg2, ink, sub, accent, accentInk), so a later colour change knows what to replace. */
|
||||
palette?: { bg: string; bg2: string; ink: string; sub: string; accent: string; accentInk: string };
|
||||
canvas: Record<string, unknown>;
|
||||
}
|
||||
export const MAX_SIDE = 4096;
|
||||
export const MIN_SIDE = 200;
|
||||
export function validSize(width: number, height: number): boolean {
|
||||
return Number.isInteger(width) && Number.isInteger(height) && width >= MIN_SIDE && height >= MIN_SIDE && width <= MAX_SIDE && height <= MAX_SIDE;
|
||||
}
|
||||
const COLOR = /^#[\da-f]{6}$/i;
|
||||
export function validBackground(value: unknown): value is Background {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const b = value as Record<string, unknown>;
|
||||
if (b.kind === 'solid') return typeof b.color === 'string' && COLOR.test(b.color);
|
||||
return b.kind === 'linear' && typeof b.from === 'string' && COLOR.test(b.from) && typeof b.to === 'string' && COLOR.test(b.to) && typeof b.angle === 'number' && Number.isFinite(b.angle);
|
||||
}
|
||||
/** File name from a template such as `{name}-{platform}-{date}`; never yields path separators. */
|
||||
export function renderFilename(template: string, vars: Record<string, string>): string {
|
||||
const out = (template || '{name}').replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? '');
|
||||
return safeName(out.replace(/\s+/g, ' ').trim()).replace(/ +/g, '-');
|
||||
}
|
||||
export function folderPath(value: string): string {
|
||||
const path = value.trim().replace(/\\/g,'/').replace(/\/+$/,'');
|
||||
if (!path || path.startsWith('/') || /^[a-z]:/i.test(path) || path.split('/').some(p => !p || p === '..' || p === '.') || /[\x00-\x1f]/.test(path)) throw new Error('invalid-folder');
|
||||
return path;
|
||||
}
|
||||
export function safeName(value: string): string {
|
||||
return value.replace(/[\\/:*?"<>|\x00-\x1f]/g,' ').trim().replace(/^\.+|\.+$/g,'').trim().slice(0,100) || 'Cover';
|
||||
}
|
||||
function checkCanvas(value: unknown, depth = 0): void {
|
||||
if (depth > 40) throw new Error('invalid-design');
|
||||
if (!value || typeof value !== 'object') return;
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') throw new Error('invalid-design');
|
||||
// Persisted image objects must be self-contained raster images. Never request remote URLs.
|
||||
if (key === 'src' && (typeof item !== 'string' || !/^data:image\/(png|jpeg|webp);base64,[a-z0-9+/=\s]+$/i.test(item))) throw new Error('invalid-design');
|
||||
checkCanvas(item, depth + 1);
|
||||
}
|
||||
}
|
||||
export function parseDesign(raw: string): Design {
|
||||
if (raw.length > 40_000_000) throw new Error('invalid-design');
|
||||
const d = JSON.parse(raw) as Design;
|
||||
if (!d || d.format !== 'qiaomu-cover-design' || d.schema !== 1 || !validSize(d.width,d.height) || !d.canvas || !Array.isArray(d.canvas.objects) || d.canvas.objects.length > 500 || (d.source !== undefined && typeof d.source !== 'string') || (d.platform !== undefined && typeof d.platform !== 'string') || (d.template !== undefined && typeof d.template !== 'string') || (d.bg !== undefined && !validBackground(d.bg)) || (d.export !== undefined && (typeof d.export !== 'object' || d.export === null))) throw new Error('invalid-design');
|
||||
checkCanvas(d.canvas);
|
||||
if (d.palette !== undefined && (typeof d.palette !== 'object' || d.palette === null || !['bg', 'bg2', 'ink', 'sub', 'accent', 'accentInk'].every(k => /^#[\da-f]{6}$/i.test(String((d.palette as Record<string, unknown>)[k]))))) delete d.palette;
|
||||
return d;
|
||||
}
|
||||
/**
|
||||
* Undo snapshots would otherwise hold one full copy of every embedded image per step.
|
||||
* Images are replaced by a shared reference so 40 steps cost one copy.
|
||||
*/
|
||||
export class SnapshotCodec {
|
||||
private images: string[] = []; private index = new Map<string, number>();
|
||||
encode(design: Design): string {
|
||||
return JSON.stringify(design, (key, value: unknown) => {
|
||||
if (key !== 'src' || typeof value !== 'string' || !value.startsWith('data:image/')) return value;
|
||||
let id = this.index.get(value);
|
||||
if (id === undefined) { id = this.images.length; this.images.push(value); this.index.set(value, id); }
|
||||
return `@img:${id}`;
|
||||
});
|
||||
}
|
||||
decode(snapshot: string): Design {
|
||||
return JSON.parse(snapshot, (key, value: unknown) => {
|
||||
if (key === 'src' && typeof value === 'string' && value.startsWith('@img:')) {
|
||||
const image = this.images[Number(value.slice(5))];
|
||||
if (image === undefined) throw new Error('invalid-design');
|
||||
return image;
|
||||
}
|
||||
return value;
|
||||
}) as Design;
|
||||
}
|
||||
}
|
||||
export class History {
|
||||
private entries: string[] = []; private index = -1;
|
||||
reset(value: string): void { this.entries = [value]; this.index = 0; }
|
||||
/** Font metrics can normalize a restored snapshot without creating a new edit or losing redo. */
|
||||
replaceCurrent(value: string): void { if (this.index >= 0) this.entries[this.index] = value; }
|
||||
push(value: string): void {
|
||||
if (value === this.entries[this.index]) return;
|
||||
this.entries = this.entries.slice(0,this.index + 1); this.entries.push(value);
|
||||
if (this.entries.length > 40) this.entries.shift();
|
||||
this.index = this.entries.length - 1;
|
||||
}
|
||||
canStep(direction: -1|1): boolean { const i = this.index + direction; return i >= 0 && i < this.entries.length; }
|
||||
step(direction: -1|1): string|undefined {
|
||||
const index = this.index + direction;
|
||||
if (index < 0 || index >= this.entries.length) return;
|
||||
this.index = index; return this.entries[index];
|
||||
}
|
||||
}
|
||||
export class SerialWriter {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
run(task: () => Promise<void>): Promise<void> {
|
||||
const result = this.tail.then(task); this.tail = result.catch(() => {}); return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Platform presets: sizes, feed overlays that cover parts of a cover, and file-size limits. */
|
||||
export interface Zone { x: number; y: number; w: number; h: number; zh: string; en: string }
|
||||
export interface Platform {
|
||||
id: string; zh: string; en: string; icon: string; group: 'social' | 'video' | 'general';
|
||||
width: number; height: number; zhHint: string; enHint: string;
|
||||
/** Inner text-safe margin as a fraction of the shorter side. */
|
||||
margin: number;
|
||||
/** Areas the platform usually draws over the cover (timestamps, counters, buttons). */
|
||||
avoid: Zone[];
|
||||
/** Upload limit in bytes when the platform has a documented one. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export const PLATFORMS: Platform[] = [
|
||||
{ id: 'xhs', zh: '小红书 · 3:4', en: 'Xiaohongshu · 3:4', icon: 'book-heart', group: 'social', width: 1080, height: 1440, margin: 0.06, avoid: [],
|
||||
zhHint: '图文笔记封面,3:4 竖版最常用', enHint: 'Note cover, 3:4 portrait is the standard' },
|
||||
{ id: 'xhs-square', zh: '小红书 · 1:1', en: 'Xiaohongshu · 1:1', icon: 'square', group: 'social', width: 1080, height: 1080, margin: 0.07, avoid: [],
|
||||
zhHint: '方形封面,信息流中更紧凑', enHint: 'Square cover, more compact in the feed' },
|
||||
{ id: 'youtube', zh: 'YouTube 缩略图', en: 'YouTube thumbnail', icon: 'play', group: 'video', width: 1280, height: 720, margin: 0.04, maxBytes: 2 * 1024 * 1024,
|
||||
avoid: [{ x: 0.82, y: 0.86, w: 0.18, h: 0.14, zh: '时长标签', en: 'Duration' }],
|
||||
zhHint: '1280×720,文件不超过 2MB,右下角会被时长遮挡', enHint: '1280×720, under 2MB; the duration badge covers the bottom-right corner' },
|
||||
{ id: 'bilibili', zh: 'B 站封面 · 16:10', en: 'Bilibili cover · 16:10', icon: 'tv', group: 'video', width: 1146, height: 717, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.84, w: 1, h: 0.16, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1146×717,信息流底部会叠加播放与弹幕数据', enHint: '1146×717; the feed overlays play counts along the bottom' },
|
||||
{ id: 'bilibili-43', zh: 'B 站投稿 · 4:3', en: 'Bilibili upload · 4:3', icon: 'tv', group: 'video', width: 1200, height: 900, margin: 0.05,
|
||||
avoid: [{ x: 0, y: 0, w: 1, h: 0.08, zh: '信息流裁切', en: 'Cropped in feed' }, { x: 0, y: 0.78, w: 1, h: 0.22, zh: '裁切 + 播放数据条', en: 'Crop + stats bar' }],
|
||||
zhHint: '1200×900 投稿封面;信息流里按 16:10 裁切,文字放在中间 80% 内', enHint: '1200×900 upload cover; the feed crops it to 16:10, keep type in the middle band' },
|
||||
{ id: 'bilibili-hd', zh: 'B 站 · 16:9 高清', en: 'Bilibili · 16:9 HD', icon: 'tv', group: 'video', width: 1920, height: 1080, margin: 0.04,
|
||||
avoid: [{ x: 0, y: 0.86, w: 1, h: 0.14, zh: '播放数据条', en: 'Stats bar' }],
|
||||
zhHint: '1920×1080,横版高清封面', enHint: '1920×1080 widescreen cover' },
|
||||
{ id: 'vertical', zh: '抖音 / 视频号 · 9:16', en: 'Douyin / Shorts · 9:16', icon: 'smartphone', group: 'video', width: 1080, height: 1920, margin: 0.06,
|
||||
avoid: [{ x: 0, y: 0.78, w: 0.8, h: 0.22, zh: '标题与账号', en: 'Caption' }, { x: 0.84, y: 0.5, w: 0.16, h: 0.4, zh: '互动按钮', en: 'Buttons' }],
|
||||
zhHint: '竖屏短视频封面,底部和右侧会被界面遮挡', enHint: 'Vertical video; captions and buttons cover the bottom and right' },
|
||||
{ id: 'wechat', zh: '公众号头图 · 2.35:1', en: 'WeChat banner · 2.35:1', icon: 'message-circle', group: 'social', width: 1410, height: 600, margin: 0.05, avoid: [],
|
||||
zhHint: '文章头图,列表中会被裁为 1:1 小图,主体放中间', enHint: 'Article banner; the feed crops a square from the centre' },
|
||||
{ id: 'x', zh: 'X 封面 · 5:2', en: 'X header · 5:2', icon: 'at-sign', group: 'social', width: 1500, height: 600, margin: 0.05, avoid: [{ x: 0, y: 0.55, w: 0.3, h: 0.45, zh: '头像', en: 'Avatar' }],
|
||||
zhHint: '个人主页横幅,头像会遮住左下角', enHint: 'Profile header; the avatar covers the lower-left' },
|
||||
{ id: 'square', zh: '方形 · 1:1', en: 'Square · 1:1', icon: 'square', group: 'general', width: 1080, height: 1080, margin: 0.06, avoid: [], zhHint: '通用方形', enHint: 'General square' },
|
||||
{ id: 'wide', zh: '横版 · 16:9', en: 'Landscape · 16:9', icon: 'rectangle-horizontal', group: 'general', width: 1920, height: 1080, margin: 0.05, avoid: [], zhHint: '通用横版', enHint: 'General landscape' },
|
||||
{ id: 'portrait', zh: '竖版 · 3:4', en: 'Portrait · 3:4', icon: 'rectangle-vertical', group: 'general', width: 1200, height: 1600, margin: 0.06, avoid: [], zhHint: '通用竖版', enHint: 'General portrait' },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLATFORM = 'xhs';
|
||||
export function platformById(id: string | undefined): Platform | undefined { return PLATFORMS.find(p => p.id === id); }
|
||||
export function platformFor(width: number, height: number, id?: string): Platform | undefined {
|
||||
const exact = platformById(id);
|
||||
if (exact && exact.width === width && exact.height === height) return exact;
|
||||
return PLATFORMS.find(p => p.width === width && p.height === height);
|
||||
}
|
||||
export function platformName(p: Platform, zh: boolean): string { return zh ? p.zh : p.en; }
|
||||
export const GROUPS: { id: Platform['group']; zh: string; en: string }[] = [
|
||||
{ id: 'social', zh: '图文平台', en: 'Social' },
|
||||
{ id: 'video', zh: '视频平台', en: 'Video' },
|
||||
{ id: 'general', zh: '通用比例', en: 'General' },
|
||||
];
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Automatic checks that run after the designer lays a cover out: text must stay readable on its background and clear of the
|
||||
* parts of a platform's UI that cover the image (avatar, duration badge, stats bar). Pure geometry and colour, no canvas.
|
||||
*/
|
||||
export interface Box { x: number; y: number; w: number; h: number }
|
||||
export interface Zone extends Box { zh: string; en: string }
|
||||
|
||||
const hexRgb = (hex: string): [number, number, number] | undefined => {
|
||||
const m = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(hex.trim()); if (!m) return undefined; let h = m[1]!;
|
||||
if (h.length === 3) h = [...h].map(c => c + c).join('');
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||
};
|
||||
const lum = ([r, g, b]: [number, number, number]): number => {
|
||||
const f = (v: number): number => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
/** WCAG contrast ratio (1–21) between two #hex colours; undefined when either is not plain hex. */
|
||||
export function contrast(a: string, b: string): number | undefined {
|
||||
const x = hexRgb(a), y = hexRgb(b); if (!x || !y) return undefined;
|
||||
const [hi, lo] = [lum(x), lum(y)].sort((p, q) => q - p) as [number, number]; return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
/** Whichever of near-black / white reads better on `bg`. */
|
||||
export function readableOn(bg: string): string { return (contrast('#111111', bg) ?? 21) >= (contrast('#ffffff', bg) ?? 1) ? '#111111' : '#ffffff'; }
|
||||
/** Keeps `fg` when it already has `min` contrast on `bg`, otherwise returns a readable replacement. */
|
||||
export function ensureReadable(fg: string, bg: string, min = 3): string {
|
||||
const c = contrast(fg, bg); return c === undefined || c >= min ? fg : readableOn(bg);
|
||||
}
|
||||
/**
|
||||
* The same hue, mixed toward black (or white on a dark ground) in small steps until it reaches `min` contrast on `bg`.
|
||||
* For an accent that carries words: the colour stays recognisable instead of being swapped for black or white.
|
||||
*/
|
||||
export function toContrast(fg: string, bg: string, min = 3): string {
|
||||
const a = hexRgb(fg), b = hexRgb(bg); if (!a || !b || (contrast(fg, bg) ?? 21) >= min) return fg;
|
||||
const to = lum(b) > 0.4 ? 0 : 255; let out = fg;
|
||||
for (let k = 1; k <= 20; k++) {
|
||||
out = '#' + a.map(v => Math.round(v + (to - v) * k * 0.05).toString(16).padStart(2, '0')).join('');
|
||||
if ((contrast(out, bg) ?? 0) >= min) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const hit = (a: Box, b: Box): boolean => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||
/**
|
||||
* Where a text box should sit so it clears every avoid zone: the same x, moved up for zones in the lower half and down for
|
||||
* zones in the upper half. Undefined when it already clears them or no move would keep it on the canvas.
|
||||
*/
|
||||
export function clearOfZones(box: Box, zones: Box[], canvasH: number, pad: number): number | undefined {
|
||||
let y = box.y; let moved = false;
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const blocking = zones.find(z => hit({ ...box, y }, z)); if (!blocking) break;
|
||||
const next = blocking.y + blocking.h / 2 > canvasH / 2 ? blocking.y - box.h - pad : blocking.y + blocking.h + pad; y = next; moved = true;
|
||||
}
|
||||
if (!moved || y < 0 || y + box.h > canvasH) return undefined; return y;
|
||||
}
|
||||
|
||||
/**
|
||||
* How big the words are when the cover is a card in a phone feed. Widths are the typical rendered card width in CSS px:
|
||||
* a Xiaohongshu two-column card, a YouTube/Bilibili sidebar thumbnail, a WeChat share card.
|
||||
*/
|
||||
export const FEED_WIDTH: Record<string, number> = { xhs: 170, 'xhs-square': 170, portrait: 170, square: 170, youtube: 168, bilibili: 170, 'bilibili-43': 170, 'bilibili-hd': 170, wide: 168, vertical: 120, wechat: 150, x: 300 };
|
||||
export interface ThumbText { role: 'title' | 'subtitle'; size: number; text: string }
|
||||
export interface ThumbIssue { role: 'title' | 'subtitle'; kind: 'small' | 'long'; px?: number; min?: number }
|
||||
/** Below these the words blur into a smudge at feed size (glyph height in CSS px). */
|
||||
export const THUMB_MIN = { title: 11, subtitle: 6 };
|
||||
/** Problems a cover has at feed size: a headline or subtitle too small to read, or a headline too long to take in at a glance. */
|
||||
export function thumbCheck(texts: ThumbText[], canvasWidth: number, platformId?: string): ThumbIssue[] {
|
||||
const k = (FEED_WIDTH[platformId ?? ''] ?? 150) / canvasWidth; const out: ThumbIssue[] = [];
|
||||
for (const t of texts) {
|
||||
const px = Math.round(t.size * k * 10) / 10; const min = THUMB_MIN[t.role];
|
||||
if (px < min) out.push({ role: t.role, kind: 'small', px, min });
|
||||
}
|
||||
const title = texts.find(t => t.role === 'title');
|
||||
if (title) { const flat = title.text.replace(/\s+/g, ''); const units = /[-鿿]/.test(flat) ? [...flat].length : flat.length / 2.2; if (units > 15) out.push({ role: 'title', kind: 'long' }); }
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Font pairing rules. A cover uses at most two families: one display face for the title (and badge) and one calm face for everything
|
||||
* else. Each template names a mood; the mood lists candidates best first, and the first installed one wins, so a cover always looks
|
||||
* right with what the user has and better with what the starter pack installs.
|
||||
*
|
||||
* Rules the lists encode:
|
||||
* 1. Title = display face, body = neutral face. Never two display faces.
|
||||
* 2. Weight contrast does the hierarchy: a single-weight heavy title over a regular body, never bold-on-bold.
|
||||
* 3. Heavy display faces are single-weight, so they are never faux-bolded.
|
||||
* 4. Serif titles pair with a warm kai/song body; sans titles with a plain gothic body.
|
||||
*/
|
||||
export type Mood = 'heavy' | 'punch' | 'tech' | 'trend' | 'warm' | 'serif' | 'brush';
|
||||
export interface Pair { title: string[]; body: string[]; zh: string }
|
||||
|
||||
// The default font library comes first everywhere, so a fresh install looks exactly like the design; system faces only catch
|
||||
// the moment before the library has landed.
|
||||
const BODY = ['思源黑体', 'PingFang SC', 'Microsoft YaHei'];
|
||||
export const MOODS: Record<Mood, Pair> = {
|
||||
heavy: { zh: '稳重有力', title: ['思源黑体 Heavy', '思源黑体 Bold', 'PingFang SC'], body: BODY },
|
||||
punch: { zh: '冲击抓眼', title: ['抖音美好体', '思源黑体 Heavy', '思源黑体 Bold', 'PingFang SC'], body: BODY },
|
||||
tech: { zh: '科技数据', title: ['未来荧黑', '得意黑', '思源黑体 Heavy'], body: BODY },
|
||||
trend: { zh: '潮流年轻', title: ['得意黑', '抖音美好体', '站酷庆科黄油体'], body: BODY },
|
||||
warm: { zh: '亲切活泼', title: ['江城圆体', '站酷庆科黄油体', '猫啃什锦黑', '抖音美好体'], body: BODY },
|
||||
serif: { zh: '文艺深度', title: ['思源宋体 Heavy', '朱雀仿宋', '思源宋体 Bold', 'Songti SC'], body: ['霞鹜文楷', '思源宋体', ...BODY] },
|
||||
brush: { zh: '国风书法', title: ['马善政楷书', '志莽行书', '朱雀仿宋'], body: ['朱雀仿宋', '霞鹜文楷', '思源宋体', ...BODY] },
|
||||
};
|
||||
|
||||
const TEMPLATE_FACES: Record<string, string[]> = {
|
||||
keyword: ['得意黑'], riso: ['站酷快乐体', '站酷庆科黄油体'],
|
||||
newspaper: ['思源宋体 Heavy'], mag: ['朱雀仿宋', '思源宋体 Heavy'],
|
||||
notes: ['霞鹜文楷'], numeral: ['未来荧黑'],
|
||||
window: ['未来荧黑', '得意黑'], brush: ['抖音美好体', '思源黑体 Heavy', '思源黑体 Bold'],
|
||||
polaroid: ['霞鹜文楷', '站酷庆科黄油体'], stack: ['抖音美好体', '思源黑体 Heavy', '思源黑体 Bold'],
|
||||
};
|
||||
|
||||
const MOOD_OF: Record<string, Mood> = {
|
||||
minimal: 'heavy', editorial: 'serif', bold: 'punch', dark: 'tech', poster: 'heavy', split: 'heavy', sticker: 'warm', gradient: 'trend', center: 'punch',
|
||||
checklist: 'warm', number: 'tech', impact: 'punch', photo: 'heavy', bili: 'punch', compare: 'punch', neo: 'punch', soft: 'serif', swiss: 'heavy',
|
||||
acid: 'trend', collage: 'warm', mega: 'heavy', ticker: 'tech', memo: 'warm', pop: 'trend', mag: 'serif', print: 'heavy', glass: 'heavy', bento: 'heavy',
|
||||
neon: 'tech', calm: 'serif', seal: 'brush', quote: 'serif', cinema: 'serif',
|
||||
highlight: 'punch', notes: 'heavy', chat: 'heavy', keyword: 'punch', riso: 'trend', ticket: 'heavy', window: 'tech', newspaper: 'serif', polaroid: 'warm',
|
||||
stack: 'heavy', serial: 'heavy', aurora: 'tech', folio: 'heavy', sage: 'heavy', numeral: 'tech',
|
||||
regeng: 'heavy', interview: 'punch', brush: 'punch', calendar: 'heavy', frame: 'heavy', kicker: 'heavy', corner: 'heavy',
|
||||
};
|
||||
export const moodOf = (templateId: string | undefined): Mood => MOOD_OF[templateId ?? ''] ?? 'heavy';
|
||||
|
||||
/** Faces that ship a real bold. Everything else in the lists is single-weight and must not be faux-bolded. */
|
||||
// Library faces are one weight per family (思源黑体 / Bold / Heavy are separate families), so only system faces get a real bold.
|
||||
const HAS_BOLD = new Set(['PingFang SC', 'Songti SC', 'Microsoft YaHei', 'MiSans', '鸿蒙黑体', '阿里巴巴普惠体']);
|
||||
|
||||
export interface Resolved { title?: string; body?: string; titleBold: boolean; /** Best title/body families that are not installed yet. */ missing: string[] }
|
||||
/** Picks the first installed family of each list. `missing` names the top choice per role when it is not installed. */
|
||||
export function resolvePair(templateId: string | undefined, have: (family: string) => boolean): Resolved {
|
||||
const mood = MOODS[moodOf(templateId)];
|
||||
const title = [...(TEMPLATE_FACES[templateId ?? ''] ?? []), ...mood.title, ...BODY].find(have), body = mood.body.find(have);
|
||||
const missing = [mood.title[0], mood.body[0]].filter((f): f is string => !!f && !have(f));
|
||||
return { ...(title ? { title } : {}), ...(body ? { body } : {}), titleBold: !!title && HAS_BOLD.has(title), missing: [...new Set(missing)] };
|
||||
}
|
||||
|
||||
/** The pairing the template builders read while they build. Set by the view just before `build`, cleared right after. */
|
||||
let active: Resolved | undefined;
|
||||
export function usePairing(r: Resolved | undefined): void { active = r; }
|
||||
const GENERIC = new Set(['sans-serif', 'serif']);
|
||||
/** Swaps the generic family a builder asked for with the paired one. Monospace and explicit families are left alone. */
|
||||
export function typeFor(role: string | undefined, family: string | undefined, weight: string | undefined): { fontFamily?: string; fontWeight?: string } {
|
||||
if (!active || !family || !GENERIC.has(family)) return {};
|
||||
const display = role === 'title' || role === 'badge';
|
||||
const face = display ? active.title : active.body;
|
||||
if (!face) return {};
|
||||
return { fontFamily: face, ...(display && !active.titleBold && (weight === 'bold' || weight === '700') ? { fontWeight: 'normal' } : {}) };
|
||||
}
|
||||
/** Family the title is measured with, so wrapping decisions match what is drawn. */
|
||||
export const titleFace = (): string => (active?.title ? `"${active.title}"` : 'sans-serif');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createPosterDocument, parsePosterDocument } from "@edgeever/shared";
|
||||
import { savePosterMemo } from "./poster-save";
|
||||
const memo = { id: "memo", title: "Poster", revision: 3, contentHash: "old", tags: ["design"] };
|
||||
test("saves source with optimistic revision/hash checks and immutable preview", async () => {
|
||||
let payload;
|
||||
const repository = {
|
||||
uploadMemoResource: async () => ({ resource: { id: "res_new" } }),
|
||||
updateMemo: async (_memo, input) => { payload = input; return { memo: { ...memo, ...input } }; },
|
||||
deleteResource: async () => { throw new Error("must keep committed preview"); },
|
||||
};
|
||||
await savePosterMemo(repository, memo, createPosterDocument("Hello"), "Poster", "session", new Blob(["png"]));
|
||||
expect(payload).toMatchObject({ expectedRevision: 3, expectedContentHash: "old", editSessionId: "session", tags: ["design"] });
|
||||
expect(parsePosterDocument(payload.contentMarkdown)?.previewResourceId).toBe("res_new");
|
||||
expect(JSON.stringify(payload.contentJson)).not.toContain("edgeever-poster-v1");
|
||||
});
|
||||
test("failed memo save removes only its uncommitted preview, never the previous revision", async () => {
|
||||
const deleted = [];
|
||||
const repository = {
|
||||
uploadMemoResource: async () => ({ resource: { id: "res_new" } }),
|
||||
updateMemo: async () => { throw new Error("revision conflict"); },
|
||||
getMemo: async () => ({ memo }),
|
||||
deleteResource: async (id) => { deleted.push(id); },
|
||||
};
|
||||
await expect(savePosterMemo(repository, memo, { ...createPosterDocument(), previewResourceId: "res_old" }, "Poster", "session", new Blob(["png"]))).rejects.toThrow("revision conflict");
|
||||
expect(deleted).toEqual(["res_new"]);
|
||||
});
|
||||
test("metadata-only saves reuse the existing preview and persist edited tags", async () => {
|
||||
let payload;
|
||||
const repository = {
|
||||
uploadMemoResource: async () => { throw new Error("should not upload"); },
|
||||
updateMemo: async (_memo, input) => { payload = input; return { memo }; },
|
||||
};
|
||||
const result = await savePosterMemo(repository, memo, { ...createPosterDocument(), previewResourceId: "res_old" }, "New title", "session", undefined, ["poster", "layout"]);
|
||||
expect(result.document.previewResourceId).toBe("res_old");
|
||||
expect(payload.tags).toEqual(["poster", "layout"]);
|
||||
});
|
||||
|
||||
test("an uncertain storage failure retains a preview already referenced by source", async () => {
|
||||
const { serializePosterDocument } = await import("@edgeever/shared");
|
||||
const document = { ...createPosterDocument(), previewResourceId: "res_new" };
|
||||
let deleted = false;
|
||||
const repository = {
|
||||
uploadMemoResource: async () => ({ resource: { id: "res_new" } }),
|
||||
updateMemo: async () => { throw new Error("acknowledgement lost"); },
|
||||
getMemo: async () => ({ memo: { ...memo, contentMarkdown: serializePosterDocument(document) } }),
|
||||
deleteResource: async () => { deleted = true; },
|
||||
};
|
||||
await expect(savePosterMemo(repository, memo, document, "Poster", "session", new Blob(["png"]))).rejects.toThrow("acknowledgement lost");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { markdownToDoc, posterFallbackMarkdown, parsePosterDocument, serializePosterDocument, type MemoDetail, type PosterDocument } from "@edgeever/shared";
|
||||
import type { EdgeEverRepository } from "./repository";
|
||||
|
||||
// Immutable previews keep revision restores and concurrent drafts independent.
|
||||
// Only artwork changes upload a new, bounded-size preview; metadata edits reuse it.
|
||||
export const savePosterMemo = async (repository: EdgeEverRepository, memo: MemoDetail, document: PosterDocument, title: string, editSessionId: string, preview?: Blob, tags: string[] = memo.tags) => {
|
||||
let previewId: string | undefined;
|
||||
let committed = false;
|
||||
try {
|
||||
if (preview) {
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false) throw new Error("Reconnect to save the poster preview. Your local draft is preserved.");
|
||||
const { resource } = await repository.uploadMemoResource(memo.id, new File([preview], "poster-preview.png", { type: "image/png" }));
|
||||
previewId = resource.id;
|
||||
}
|
||||
const next = { ...document, ...(previewId ? { previewResourceId: previewId } : {}) };
|
||||
const result = await repository.updateMemo(memo, {
|
||||
expectedRevision: memo.revision, expectedContentHash: memo.contentHash, editSessionId, title,
|
||||
contentMarkdown: serializePosterDocument(next), contentJson: markdownToDoc(posterFallbackMarkdown(next)), tags,
|
||||
});
|
||||
committed = true;
|
||||
return { memo: result.memo, document: next };
|
||||
} finally {
|
||||
if (previewId && !committed) {
|
||||
// A storage failure can arrive after a local commit. Keep the attachment
|
||||
// unless the current source confirms that it was never referenced.
|
||||
const latest = await repository.getMemo(memo.id).catch(() => null);
|
||||
if (latest && parsePosterDocument(latest.memo.contentMarkdown)?.previewResourceId !== previewId) {
|
||||
await repository.deleteResource(previewId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html><html><meta charset="utf-8"><title>Poster design validation</title><style>body{font:14px system-ui;background:#eef0f3}#results{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}article{padding:10px;background:white}img{width:100%}pre{white-space:pre-wrap}</style><h1>Real template rendering and persistence check</h1><output id="summary">Running</output><pre id="errors"></pre><div id="results"></div><script type="module">
|
||||
import {buildPosterDesign,DESIGN_TEMPLATES,PLATFORMS} from '/src/lib/poster-design/templates.ts';
|
||||
import {posterObject,posterElementFromObject,renderPosterThumbnail,ensurePosterContrast,ActiveSelection,Canvas} from '/src/lib/poster-canvas.ts';
|
||||
import {parsePosterDocument,serializePosterDocument} from '@edgeever/shared';
|
||||
let passed=0,failed=0;const errors=[];
|
||||
for(const size of PLATFORMS.map(({width,height})=>({width,height})))for(const template of DESIGN_TEMPLATES){try{
|
||||
const doc=await buildPosterDesign({id:template.id,title:'把想法\n变成作品',subtitle:'认真记录,让每个好想法被看见。',badge:'创作笔记',...size,zh:true});
|
||||
const parsed=parsePosterDocument(serializePosterDocument(doc));if(JSON.stringify(parsed)!==JSON.stringify(doc))throw new Error('source roundtrip differs');
|
||||
const src=await renderPosterThumbnail(doc);passed++;if(size.width===1080&&size.height===1440){const item=document.createElement('article');item.innerHTML=`<img src="${src}"><p>${template.zh} · ${doc.elements.length}</p>`;document.querySelector('#results').append(item)}else URL.revokeObjectURL(src);
|
||||
}catch(error){failed++;errors.push(template.id+' '+size.width+'x'+size.height+': '+error.message);document.querySelector('#errors').textContent=errors.join('\n')}
|
||||
document.querySelector('#summary').textContent=`${passed} passed, ${failed} failed`;
|
||||
}
|
||||
// Persisted geometry must match the world coordinates of an active multi-selection.
|
||||
try{const canvas=new Canvas(document.createElement('canvas'),{width:1080,height:1440});const doc=await buildPosterDesign({id:'folio',title:'几何验证',subtitle:'保存前后保持排版',width:1080,height:1440,zh:true});const es=doc.elements.filter(e=>e.type==='text').slice(0,2);const os=await Promise.all(es.map(e=>posterObject(e,async()=>{throw Error('unexpected resource')})));canvas.add(...os);const before=os.map(o=>o.calcTransformMatrix());const group=new ActiveSelection(os,{canvas});canvas.setActiveObject(group);group.set({left:group.left+100,top:group.top+50,angle:15,scaleX:1.2,scaleY:1.2});for(let i=0;i<os.length;i++){const next=posterElementFromObject(es[i],os[i]);const restored=await posterObject(next,async()=>{throw Error('unexpected resource')});const a=os[i].calcTransformMatrix(),b=restored.calcTransformMatrix();if(a.some((v,j)=>Math.abs(v-b[j])>1.5))throw Error('multi-selection transform differs '+JSON.stringify({a,b,next}));}await canvas.dispose();passed++;}catch(e){failed++;errors.push(e.message);document.querySelector('#errors').textContent=errors.join('\n')}
|
||||
try { const input=await buildPosterDesign({id:'window',title:'对比度验证',subtitle:'白色卡片上的文字必须可读',badge:'EDGE EVER',width:1080,height:1440,zh:true,palette:{bg:'#0b163b',ink:'#ffffff',accent:'#2563eb'}});const fixed=await ensurePosterContrast(input,async()=>{throw Error('unexpected image')});const title=fixed.elements.find(e=>e.type==='text'&&e.role==='title');if(!title||title.fill==='#ffffff')throw Error('AI contrast not corrected');if(input.elements.length!==fixed.elements.length)throw Error('contrast check changed structure');passed++;}catch(e){failed++;errors.push(e.message);document.querySelector('#errors').textContent=errors.join('\n')}
|
||||
document.querySelector('#summary').textContent=`DONE: ${passed} passed, ${failed} failed`;
|
||||
</script></html>
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "1.100.0",
|
||||
"version": "1.107.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.5.1",
|
||||
"@edgeever/public-network": "workspace:*",
|
||||
@@ -246,6 +246,7 @@
|
||||
"ai": "7.0.58",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"docx-preview": "0.4.1",
|
||||
"fabric": "7.4.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lowlight": "^3.3.0",
|
||||
@@ -361,6 +362,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
||||
|
||||
"@astrojs/check": ["@astrojs/check@0.5.10", "", { "dependencies": { "@astrojs/language-server": "^2.8.4", "chokidar": "^3.5.3", "fast-glob": "^3.3.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "dist/bin.js" } }, "sha512-vliHXM9cu/viGeKiksUM4mXfO816ohWtawTl2ADPgTsd4nUMjFiyAl7xFZhF34yy4hq4qf7jvK1F2PlR3b5I5w=="],
|
||||
|
||||
"@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
|
||||
@@ -693,6 +696,16 @@
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||
|
||||
"@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="],
|
||||
|
||||
"@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="],
|
||||
@@ -1891,6 +1904,8 @@
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
@@ -1915,6 +1930,8 @@
|
||||
|
||||
"bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
@@ -1951,6 +1968,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
|
||||
|
||||
"canvas": ["canvas@3.2.3", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -2095,6 +2114,8 @@
|
||||
|
||||
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
||||
|
||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
|
||||
@@ -2183,6 +2204,8 @@
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
@@ -2197,6 +2220,8 @@
|
||||
|
||||
"decamelize": ["decamelize@6.0.1", "", {}, "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
@@ -2421,6 +2446,8 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"expo": ["expo@57.0.18", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.20", "@expo/config": "~57.0.9", "@expo/config-plugins": "~57.0.9", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.11", "@expo/local-build-cache-provider": "^57.0.8", "@expo/log-box": "^57.0.4", "@expo/metro": "~56.0.2", "@expo/metro-config": "~57.0.12", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.9", "expo-asset": "~57.0.15", "expo-constants": "~57.0.16", "expo-file-system": "~57.0.6", "expo-font": "~57.0.2", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.12", "expo-modules-core": "~57.0.14", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-6nax9hJPhf9dWrstliXUABTJXDNIJrfJKcCtjSxuYVs2Yf127GIhKf3wsEYSFUl+LFdRbPPTNaweJePH159wZA=="],
|
||||
|
||||
"expo-asset": ["expo-asset@57.0.15", "", { "dependencies": { "@expo/image-utils": "^0.11.5", "expo-constants": "~57.0.15" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZoiUftQb1Nn1jvL6l1kZoUXvgRCx57tMQYxSsp+g4ZvY+jgVWp/THruXOtPtZVdC/WWZxPUTct53bfAm2EPDAQ=="],
|
||||
@@ -2493,6 +2520,8 @@
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"fabric": ["fabric@7.4.0", "", { "optionalDependencies": { "canvas": "^3.2.0", "jsdom": "^26.1.0" } }, "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA=="],
|
||||
|
||||
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -2579,6 +2608,8 @@
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
@@ -2619,6 +2650,8 @@
|
||||
|
||||
"getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
||||
@@ -2711,6 +2744,8 @@
|
||||
|
||||
"hot-patcher": ["hot-patcher@2.0.1", "", {}, "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
@@ -2741,6 +2776,8 @@
|
||||
|
||||
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
|
||||
@@ -2847,6 +2884,8 @@
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="],
|
||||
@@ -2909,6 +2948,8 @@
|
||||
|
||||
"jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="],
|
||||
|
||||
"jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
@@ -3265,6 +3306,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -3295,6 +3338,8 @@
|
||||
|
||||
"nanoid": ["nanoid@6.0.1", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
@@ -3309,6 +3354,8 @@
|
||||
|
||||
"node-abi": ["node-abi@4.33.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
@@ -3341,6 +3388,8 @@
|
||||
|
||||
"nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="],
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.28", "", {}, "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg=="],
|
||||
|
||||
"ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
@@ -3485,6 +3534,8 @@
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.9.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg=="],
|
||||
@@ -3755,6 +3806,8 @@
|
||||
|
||||
"round-polygon": ["round-polygon@0.6.7", "", {}, "sha512-h5rNwOEP6+EHeRuZAGI2/HsNdl6UCKIFvQXzQfv7nTICZ4/sIURe51vOmQJgk/I6KW5LjQ87HJFoASKjaIEySQ=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
|
||||
"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=="],
|
||||
@@ -3781,6 +3834,8 @@
|
||||
|
||||
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
@@ -3831,6 +3886,10 @@
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
|
||||
"simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||
@@ -3941,6 +4000,8 @@
|
||||
|
||||
"svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
|
||||
@@ -3949,6 +4010,10 @@
|
||||
|
||||
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
||||
|
||||
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
|
||||
@@ -3979,6 +4044,10 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
||||
|
||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||
@@ -3991,7 +4060,9 @@
|
||||
|
||||
"toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="],
|
||||
|
||||
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||
"tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
|
||||
|
||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
@@ -4007,6 +4078,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
@@ -4183,6 +4256,8 @@
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
||||
|
||||
"warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="],
|
||||
@@ -4201,7 +4276,7 @@
|
||||
|
||||
"webdav": ["webdav@5.10.0", "", { "dependencies": { "@buttercup/fetch": "^0.2.1", "base-64": "^1.0.0", "byte-length": "^1.0.2", "entities": "^6.0.1", "fast-xml-parser": "^5.7.2", "hot-patcher": "^2.0.1", "layerr": "^3.0.0", "md5": "^2.3.0", "minimatch": "^9.0.9", "nested-property": "^4.0.0", "node-fetch": "^3.3.2", "path-posix": "^1.0.0", "url-join": "^5.0.0", "url-parse": "^1.5.10" } }, "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
@@ -4209,7 +4284,7 @@
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
||||
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="],
|
||||
|
||||
@@ -4281,12 +4356,16 @@
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
|
||||
@@ -4329,6 +4408,8 @@
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@astrojs/check/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"@astrojs/check/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
@@ -4579,6 +4660,8 @@
|
||||
|
||||
"beautiful-mermaid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"boxen/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
@@ -4719,6 +4802,8 @@
|
||||
|
||||
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"jsdom/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"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=="],
|
||||
@@ -5143,6 +5228,8 @@
|
||||
|
||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"prebuild-install/node-abi": ["node-abi@3.98.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
@@ -5201,6 +5288,10 @@
|
||||
|
||||
"svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"temp/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
|
||||
@@ -5923,6 +6014,8 @@
|
||||
|
||||
"workbox-build/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"workbox-build/source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"@astrojs/check/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
@@ -6179,6 +6272,10 @@
|
||||
|
||||
"workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"workbox-build/source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
|
||||
|
||||
"workbox-build/source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||
|
||||
"@expo/cli/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"@expo/config-plugins/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) fn note_list_metadata(markdown: &str) -> Value {
|
||||
}
|
||||
}
|
||||
metadata.insert("infographic".to_owned(), json!(is_infographic(markdown)));
|
||||
metadata.insert("poster".to_owned(), json!(markdown.contains("<!-- edgeever-poster-v1:")));
|
||||
match table_preview(markdown) {
|
||||
Some(preview) => {
|
||||
metadata.insert("structuredTable".to_owned(), json!(true));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Poster notes
|
||||
|
||||
[简体中文](poster-notes.zh-CN.md)
|
||||
|
||||
Choose **More types → Poster (Beta)** to create an editable poster. In an ordinary note, choose **Create poster** from the note actions to create a separate poster using the title and selected text (or the first 240 characters of the body). The original note remains intact.
|
||||
|
||||
Web and desktop open a dedicated design workspace with 46 real templates adapted from Qiaomu Design's MIT-licensed layout engine, 12 platform presets, bundled licensed Chinese and Latin fonts, headline/subtitle/badge reflow, and actual rendered template previews. The template renderer and its attribution are in `apps/web/src/lib/poster-design`; font licenses ship alongside the font files. Generated raster grain is omitted when adapting vector templates.
|
||||
|
||||
The canvas supports text, badges, shapes, paths, freehand drawing and images; multi-selection, grouping, alignment, snapping, zoom and pan; layers, visibility and locking; typography, outlines, shadows, gradients and image fit/crop/flip. Export PNG, JPEG or WebP at 1×–3× (up to 8192 pixels per side), copy a PNG, or insert an exported image into the source note. Edit text directly on the canvas; the template panel has no separate copy form. Applying a template rebuilds its layout from the current canvas copy and retains separately added elements; undo restores the previous layout. Editing individual elements preserves the existing layout.
|
||||
|
||||
AI uses the shared assistant sidebar, with no separate AI panel in the poster workspace. The `get_poster` and `update_poster` tools let agents read the canvas and edit text, colors, typography and element positions through the existing agent action flow. Edits preserve unmentioned and locked elements and reject stale revisions. The editor regenerates the preview after an agent edit. This feature does not generate images.
|
||||
|
||||
## Source and persistence
|
||||
|
||||
`PosterDocument` is validated, versioned source, separate from Fabric.js state. An `edgeever-poster-v1` Markdown comment carries it; readable text and a PNG preview provide a portable fallback. Image elements reference existing workspace attachment IDs; arbitrary URLs and arbitrary Fabric object JSON are not accepted. The rendering engine loads only when entering the poster editor.
|
||||
|
||||
Saving uses the existing note revision, content hash and edit session checks. Each artwork version gets an immutable preview up to 480 pixels wide; metadata-only changes reuse it. Historical previews are retained for revision restores. This increases attachment usage with the number of saved artwork versions. Failed source saves remove only the newly uploaded, uncommitted preview.
|
||||
|
||||
Drafts are backed up on the current device. A draft based on a different note version must be downloaded and reconciled rather than silently overwriting the current note. Uploading images and saving a new preview require a network connection; offline edits remain local drafts. Inserting into the source note checks its current revision and creates an attachment owned by that note.
|
||||
|
||||
## Compatibility and rollout
|
||||
|
||||
Android and iOS show the readable preview and text and disable ordinary rich-text editing of poster notes. The API also rejects legacy saves that would strip a poster envelope. This guard requires the updated server. Earlier clients connected to an earlier server do not have that protection. Generic MCP `update_memo` cannot replace poster content.
|
||||
|
||||
No database migration or authentication/update configuration is needed. To roll back the UI, remove the create/edit entries while retaining source, attachments and reader protection. Do not convert posters into ordinary notes or delete the source marker.
|
||||
|
||||
Verification covers source round trips, format validation, preview save failures, revision conflicts and legacy contentJSON overwrite attempts in SQLite, native reader policy, and local Web creation/edit/save/reopen/export and source-note insertion. Native hardware, installed desktop cross-version flows and paid AI image generation require separate release validation; this feature does not implement image generation.
|
||||
|
||||
For the browser rendering regression harness, run `bun run dev` and open `/tests/poster-design.html`. It checks every template on all 12 platform presets, source round trips, multi-selection geometry and AI text contrast.
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user