feat(ios): upgrade native iOS note image export with 8 shared poster themes, typography options, and brand logo

This commit is contained in:
tianma-if committed 2026-08-22 23:04:46 +08:00
1 parent abeaa1d4fe
commit 6ae6effdfa
6 files changed
+1749 -842

No files matched your search

@@ -20,9 +20,11 @@ struct MemoDetailView: View {
@State private var imageExportBuffer = MemoImageExportBuffer()
@State private var imageShareOptionsOpen = false
@State private var imageShareFormat = "png"
@State private var imageShareBackground = "slate"
@State private var imageShareNotebook = true
@State private var imageShareTags = true
@State private var imageShareTheme = "slate"
@State private var imageShareFontStyle = "serif"
@State private var imageShareTitle = true
@State private var imageShareNotebook = false
@State private var imageShareTags = false
@State private var imageShareUpdatedAt = true
@State private var imageShareBranding = true
@State private var error: String?
@@ -130,15 +132,29 @@ struct MemoDetailView: View {
.sheet(isPresented: $imageShareOptionsOpen) {
NavigationStack {
Form {
Section(env.preferences.t("背景", en: "Background")) {
Picker(env.preferences.t("背景", en: "Background"), selection: $imageShareBackground) {
Text(env.preferences.t("简洁", en: "Clean")).tag("slate")
Section(env.preferences.t("主题风格", en: "Theme")) {
Picker(env.preferences.t("主题风格", en: "Theme"), selection: $imageShareTheme) {
Text(env.preferences.t("经典浅色", en: "Light")).tag("slate")
Text(env.preferences.t("极光渐变", en: "Aurora")).tag("aurora")
Text(env.preferences.t("暮色晚霞", en: "Sunset")).tag("sunset")
Text(env.preferences.t("暗夜曜石", en: "Midnight")).tag("midnight")
Text(env.preferences.t("薄荷", en: "Mint")).tag("mint")
Text(env.preferences.t("暖色", en: "Warm")).tag("warm")
Text(env.preferences.t("紫雾流光", en: "Lavender")).tag("lavender")
Text(env.preferences.t("经典便签", en: "Notepad")).tag("notepad")
Text(env.preferences.t("水墨宣纸", en: "Rice Paper")).tag("xuan")
}
.pickerStyle(.menu)
}
Section(env.preferences.t("字体风格", en: "Typography")) {
Picker(env.preferences.t("字体风格", en: "Typography"), selection: $imageShareFontStyle) {
Text(env.preferences.t("文艺衬线", en: "Serif")).tag("serif")
Text(env.preferences.t("现代无衬线", en: "Sans")).tag("sans")
Text(env.preferences.t("极客等宽", en: "Mono")).tag("mono")
}
.pickerStyle(.segmented)
}
Section(env.preferences.t("笔记信息", en: "Note information")) {
Section(env.preferences.t("显示内容", en: "Content elements")) {
Toggle(env.preferences.t("笔记标题", en: "Note title"), isOn: $imageShareTitle)
Toggle(env.preferences.t("笔记本", en: "Notebook"), isOn: $imageShareNotebook)
Toggle(env.preferences.t("标签", en: "Tags"), isOn: $imageShareTags)
Toggle(env.preferences.t("更新时间", en: "Updated time"), isOn: $imageShareUpdatedAt)
@@ -146,8 +162,8 @@ struct MemoDetailView: View {
}
Section(env.preferences.t("图片格式", en: "Image format")) {
Picker(env.preferences.t("图片格式", en: "Image format"), selection: $imageShareFormat) {
Text(env.preferences.t("PNG · 文字更清晰", en: "PNG · Best for text")).tag("png")
Text(env.preferences.t("JPEG · 文件更小", en: "JPEG · Smaller file")).tag("jpeg")
Text(env.preferences.t("PNG · 超清无损", en: "PNG · Best for text")).tag("png")
Text(env.preferences.t("JPEG · 体积小", en: "JPEG · Smaller file")).tag("jpeg")
}
.pickerStyle(.segmented)
}
@@ -165,7 +181,9 @@ struct MemoDetailView: View {
exportMemoImage(
memo,
format: imageShareFormat,
background: imageShareBackground,
theme: imageShareTheme,
fontStyle: imageShareFontStyle,
showTitle: imageShareTitle,
showNotebook: imageShareNotebook,
showTags: imageShareTags,
showUpdatedAt: imageShareUpdatedAt,
@@ -923,11 +941,13 @@ struct MemoDetailView: View {
private func exportMemoImage(
_ memo: MemoDetail,
format: String,
background: String = "slate",
showNotebook: Bool = true,
showTags: Bool = true,
theme: String = "slate",
fontStyle: String = "serif",
showTitle: Bool = true,
showNotebook: Bool = false,
showTags: Bool = false,
showUpdatedAt: Bool = true,
showBranding: Bool = false
showBranding: Bool = true
) {
guard !imageExporting, bodyReady else { return }
let requestId = UUID().uuidString
@@ -942,7 +962,12 @@ struct MemoDetailView: View {
"notebook": showNotebook ? notebookName(for: memo) : "",
"tags": showTags ? memo.tags : [],
"updatedAt": showUpdatedAt ? memo.updatedAt : "",
"background": background,
"theme": theme,
"fontStyle": fontStyle,
"showTitle": showTitle,
"showNotebook": showNotebook,
"showTags": showTags,
"showUpdatedAt": showUpdatedAt,
"branding": showBranding,
])
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3
View File
@@ -5,6 +5,7 @@
"": {
"name": "@edgeever/ios-editor",
"dependencies": {
"@edgeever/shared": "file:../../../packages/shared",
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
@@ -33,6 +34,8 @@
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@edgeever/shared": ["@edgeever/shared@file:../../../packages/shared", { "dependencies": { "@tiptap/core": "3.27.1", "@tiptap/extension-image": "3.27.1", "@tiptap/extension-list": "3.27.1", "@tiptap/extension-mathematics": "3.27.1", "@tiptap/extension-table": "3.27.1", "@tiptap/markdown": "3.27.1", "@tiptap/pm": "3.27.1", "@tiptap/starter-kit": "3.27.1", "katex": "0.16.22" } }],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+1
View File
@@ -9,6 +9,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@edgeever/shared": "file:../../../packages/shared",
"@tiptap/core": "3.27.1",
"@tiptap/extension-code-block": "3.27.1",
"@tiptap/extension-image": "3.27.1",
+66 -40
View File
@@ -12,6 +12,19 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import mermaid from "mermaid";
import { toCanvas } from "html-to-image";
import {
type NoteImageTheme,
type NoteImageFontStyle,
type NoteImageFontSize,
type NoteImageCardWidth,
NOTE_IMAGE_CARD_WIDTH_PIXELS,
NOTE_IMAGE_BACKGROUND_COLORS,
NOTE_IMAGE_THEMES,
resolveTheme,
buildImageExportBasename,
buildNoteImageCardMarkup,
generateCardCss,
} from "@edgeever/shared/note-image-card";
import { createEdgeEverMathematics } from "./mathematics";
/** Keep in sync with packages/shared MergeDivider (iOS bundle cannot import monorepo shared). */
@@ -142,7 +155,15 @@ type ImageExportRequest = {
notebook?: string;
tags?: string[];
updatedAt?: string;
background?: "mint" | "slate" | "warm";
background?: "mint" | "slate" | "warm" | NoteImageTheme;
theme?: NoteImageTheme;
fontStyle?: NoteImageFontStyle;
fontSize?: NoteImageFontSize;
cardWidth?: NoteImageCardWidth;
showTitle?: boolean;
showNotebook?: boolean;
showTags?: boolean;
showUpdatedAt?: boolean;
branding?: boolean;
};
let mode: "viewer" | "editor" = "viewer";
@@ -1089,41 +1110,49 @@ async function afterContentSet(theme: "light" | "dark" = "light") {
async function exportNoteImage(request: ImageExportRequest) {
if (!request.requestId || (request.format !== "png" && request.format !== "jpeg")) return;
const host = document.createElement("div");
host.style.cssText = `position:fixed;left:-100000px;top:0;width:${IMAGE_EXPORT_WIDTH}px;pointer-events:none;`;
const documentRoot = document.createElement("div");
documentRoot.className = "edgeever-image-document";
const card = document.createElement("article");
card.className = "edgeever-image-card";
const title = document.createElement("h1");
title.className = "edgeever-image-title";
title.textContent = request.title || request.fallbackTitle;
card.appendChild(title);
const metadata = [request.notebook, request.updatedAt, ...(request.tags ?? []).map((tag) => `#${tag}`)].filter(Boolean);
if (metadata.length > 0) {
const meta = document.createElement("div");
meta.className = "edgeever-image-meta";
meta.textContent = metadata.join(" · ");
card.appendChild(meta);
}
const content = document.createElement("div");
content.className = "edgeever-image-content";
const resolvedTheme = resolveTheme(request.background, request.theme);
const fontStyle = request.fontStyle ?? "serif";
const fontSize = request.fontSize ?? "lg";
const cardWidth = request.cardWidth ?? "standard";
const targetWidth = NOTE_IMAGE_CARD_WIDTH_PIXELS[cardWidth] || 680;
const themeCfg = NOTE_IMAGE_THEMES[resolvedTheme] || NOTE_IMAGE_THEMES.slate;
const editorClone = editor.view.dom.cloneNode(true) as HTMLElement;
editorClone.removeAttribute("contenteditable");
editorClone.querySelectorAll("button, [contenteditable='true']").forEach((element) => {
element.removeAttribute("contenteditable");
if (element instanceof HTMLButtonElement) element.remove();
});
content.appendChild(editorClone);
card.appendChild(content);
if (request.branding) {
const footer = document.createElement("footer");
footer.className = "edgeever-image-brand";
footer.innerHTML = '<span class="edgeever-image-brand-mark"></span><span>Made with <span class="edgeever-image-brand-name">EdgeEver</span></span>';
card.appendChild(footer);
}
documentRoot.appendChild(card);
host.appendChild(documentRoot);
const bodyHtml = editorClone.innerHTML;
const host = document.createElement("div");
host.style.cssText = `position:fixed;left:-100000px;top:0;width:${targetWidth}px;pointer-events:none;`;
const style = document.createElement("style");
style.textContent = generateCardCss({ theme: resolvedTheme, fontStyle, fontSize, cardWidth });
const cardMarkup = buildNoteImageCardMarkup({
title: request.title || request.fallbackTitle,
notebook: request.notebook,
tags: request.tags,
updatedAt: request.updatedAt,
bodyHtml,
theme: resolvedTheme,
fontStyle,
showTitle: request.showTitle ?? true,
showNotebook: request.showNotebook ?? false,
showTags: request.showTags ?? false,
showUpdatedAt: request.showUpdatedAt ?? true,
showBranding: request.branding ?? true,
});
host.appendChild(style);
host.insertAdjacentHTML("beforeend", cardMarkup);
const documentRoot = host.lastElementChild as HTMLElement;
documentRoot.style.width = `${targetWidth}px`;
documentRoot.style.maxWidth = "none";
documentRoot.style.margin = "0";
document.body.appendChild(host);
try {
@@ -1133,29 +1162,26 @@ async function exportNoteImage(request: ImageExportRequest) {
try { await image.decode(); } catch { /* Export the readable remainder. */ }
}));
const totalHeight = Math.max(1, Math.ceil(documentRoot.getBoundingClientRect().height));
const backgroundColor = {
mint: "#ecfdf5",
slate: "#f8fafc",
warm: "#fffbeb",
}[request.background ?? "slate"];
documentRoot.style.backgroundColor = backgroundColor;
const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg;
const canvas = await toCanvas(documentRoot, {
backgroundColor,
cacheBust: false,
height: totalHeight,
pixelRatio: IMAGE_EXPORT_PIXEL_RATIO,
pixelRatio: 2,
skipFonts: true,
width: IMAGE_EXPORT_WIDTH,
width: targetWidth,
});
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => result ? resolve(result) : reject(new Error("Image renderer returned an empty file")),
request.format === "jpeg" ? "image/jpeg" : "image/png",
request.format === "jpeg" ? 0.9 : 1,
request.format === "jpeg" ? 0.92 : 1,
);
});
const extension = request.format === "jpeg" ? "jpg" : "png";
const basename = sanitizeImageExportBasename(request.title, request.fallbackTitle);
const basename = buildImageExportBasename(request.title, request.fallbackTitle);
const bytes = await blobToBytes(blob);
const filename = `${basename}.${extension}`;
const mimeType = request.format === "jpeg" ? "image/jpeg" : "image/png";