feat(ios): upgrade native iOS note image export with 8 shared poster themes, typography options, and brand logo
This commit is contained in:
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
+819
-393
File diff suppressed because it is too large.
Load diff
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user