Files
edgeever/apps/ios/EditorSource/src/main.ts
T

1680 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./styles.css";
import "katex/dist/katex.min.css";
import { Graph } from "@antv/x6";
import { Editor } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlock from "@tiptap/extension-code-block";
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 {
createEdgeEverDocumentExtensions,
createNativeUnsupportedContentExtensions,
DETAILS_EDITOR_CSS,
diagramDocumentToX6Cells,
attachDiagramReader,
MIND_MAP_CONNECTOR_NAME,
mindMapConnector,
docToMarkdown,
NativeAttachmentMetadata,
prepareNativeEditorContent,
parseDiagramDocument,
stripDiagramDocumentMarker,
resolveAttachmentKind,
resolveNativeAttachmentContent,
restoreNativeEditorContent,
wrapDetailsContentHtml,
type TiptapDoc,
type DiagramDocument,
} from "@edgeever/shared";
import { clearMobileEditorUndoHistory, type MobileEditorLocale } from "@edgeever/shared/mobile-editor";
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,
planNativeNoteImageRender,
} from "@edgeever/shared/note-image-card";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { createIosImageGallery } from "./document-nodes";
import { createImageInsertTransaction, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "@edgeever/shared/native-image-gallery";
import { installPhoneImageFillStyle, NEW_IMAGE_WIDTH_PERCENT } from "@edgeever/shared/image-display";
const galleryStyle = document.createElement("style");
galleryStyle.textContent = NATIVE_IMAGE_GALLERY_CSS;
document.head.append(galleryStyle);
installPhoneImageFillStyle();
const detailsStyle = document.createElement("style");
detailsStyle.textContent = DETAILS_EDITOR_CSS;
document.head.append(detailsStyle);
Graph.registerConnector(MIND_MAP_CONNECTOR_NAME, mindMapConnector, true);
type BridgeMessage =
| { type: "ready"; startupMs: number }
| { type: "change"; contentMarkdown: string; contentJson: string }
| { type: "loadResource"; requestId: string; source: string }
| { type: "resourcePress"; targetJson: string }
| { type: "imagePreview"; source: string; alt: string }
| { type: "doubleTap" }
| { type: "pickImage" }
| { type: "searchResult"; count: number; index: number }
| { type: "readerScroll"; collapsed: boolean }
| { type: "imageExportChunk"; requestId: string; chunk: string }
| {
type: "imageExportComplete";
requestId: string;
filename: string;
mimeType: string;
width: number;
height: number;
totalImages: number;
failedImages: number;
}
| { type: "imageExportError"; requestId: string; message: string }
| { type: "activeFlags"; flags: number }
| { type: "log"; message: string }
| { type: "error"; message: string };
/** Match shared `getResourceIdFromUrl` — never return bare `blob`. */
function getResourceIdFromHref(href: string): string | null {
try {
const parsed = new URL(href, "http://edgeever.local");
const match = parsed.pathname.match(/^\/api\/v1\/resources\/([^/]+)(?:\/blob)?\/?$/);
if (match?.[1]) return decodeURIComponent(match[1]);
} catch {
/* ignore */
}
const loose = href.match(/\/api\/v1\/resources\/([^/?#]+)/);
if (loose?.[1] && loose[1] !== "blob") return decodeURIComponent(loose[1]);
return null;
}
function normalizeResourceHref(href: string, resourceId: string): string {
if (/\/blob(?:$|[?#])/.test(href) || href.includes(`/resources/${resourceId}/blob`)) return href;
if (href.startsWith("/api/v1/resources/") || href.includes("/api/v1/resources/")) {
return `/api/v1/resources/${encodeURIComponent(resourceId)}/blob`;
}
return href;
}
function buildImageTargetJson(src: string, filename: string): string | null {
const resourceId = getResourceIdFromHref(src);
if (!resourceId) return null;
return JSON.stringify({
kind: "image",
href: normalizeResourceHref(src, resourceId),
filename: filename.trim() || `image-${resourceId}`,
resourceId,
});
}
function buildAttachmentTargetJson(href: string, label: string): string | null {
const resourceId = getResourceIdFromHref(href);
if (!resourceId) return null;
const filename =
label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim() || resourceId;
return JSON.stringify({
kind: "attachment",
href: normalizeResourceHref(href, resourceId),
filename,
resourceId,
});
}
const ATTACHMENT_KIND_CLASS_PREFIX = "edgeever-attachment-kind-";
function normalizeAttachmentFilename(label: string): string {
return label.replace(/^\s*(?:附件[::]|Attachment:)\s*/i, "").trim();
}
function decorateAttachmentLinks(root: ParentNode): void {
root.querySelectorAll<HTMLAnchorElement>(
'a.edgeever-attachment-link, a[href*="/api/v1/resources/"]'
).forEach((link) => {
Array.from(link.classList).forEach((className) => {
if (className.startsWith(ATTACHMENT_KIND_CLASS_PREFIX)) link.classList.remove(className);
});
const filename = normalizeAttachmentFilename(link.textContent || "");
link.classList.add(
"edgeever-attachment-link",
`${ATTACHMENT_KIND_CLASS_PREFIX}${resolveAttachmentKind(null, filename)}`,
);
});
}
type ConfigureOptions = {
mode?: "viewer" | "editor";
locale?: string;
theme?: "light" | "dark";
placeholder?: string;
};
const startedAt = performance.now();
const IMAGE_EXPORT_WIDTH = 768;
const IMAGE_EXPORT_PIXEL_RATIO = 1.5;
const IMAGE_EXPORT_CHUNK_SIZE = 256 * 1024;
type ImageExportRequest = {
requestId: string;
format: "jpeg" | "png";
title: string;
fallbackTitle: string;
notebook?: string;
tags?: string[];
updatedAt?: string;
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";
let locale: MobileEditorLocale = "zh-CN";
let currentPlaceholder = "开始输入…";
let suppressChange = false;
const resourceResolvers = new Map<string, (dataUrl: string | null) => void>();
let resourceSeq = 0;
function post(msg: BridgeMessage) {
try {
(window as unknown as { webkit?: { messageHandlers?: { edgeever?: { postMessage: (m: unknown) => void } } } })
.webkit?.messageHandlers?.edgeever?.postMessage(msg);
} catch {
// native host unavailable (browser preview)
}
}
function sanitizeImageExportBasename(title: string, fallback: string) {
return title.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "-").replace(/\s+/g, " ").replace(/[. ]+$/g, "").trim().slice(0, 100) || fallback;
}
async function blobToBytes(blob: Blob) {
return new Uint8Array(await blob.arrayBuffer());
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function isProtectedResource(src: string): boolean {
return src.startsWith("/api/") || src.includes("/api/v1/resources/");
}
/** file:// editor pages often cannot load remote/protected img srcs; native must rewrite them. */
function needsNativeHydration(src: string): boolean {
if (!src || src.startsWith("data:") || src.startsWith("blob:") || src.startsWith("edgeever-res:")) {
return false;
}
// Protected API paths always need auth + rewrite.
if (isProtectedResource(src)) return true;
// Absolute remote images also need rewrite under file:// packaging.
if (src.startsWith("http://") || src.startsWith("https://")) return true;
// Root-relative non-api assets resolved via native base URL.
if (src.startsWith("/")) return true;
return false;
}
function requestResource(source: string): Promise<string | null> {
return new Promise((resolve) => {
const requestId = `r${++resourceSeq}`;
resourceResolvers.set(requestId, resolve);
post({ type: "loadResource", requestId, source });
// Timeout so broken resources don't hang forever.
setTimeout(() => {
if (resourceResolvers.delete(requestId)) resolve(null);
}, 30_000);
});
}
async function hydrateProtectedImages(root: HTMLElement) {
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img[src]"));
await Promise.all(
images.map(async (img) => {
const src = img.getAttribute("src") || "";
if (!needsNativeHydration(src)) return;
if (!img.dataset.originalSrc) img.dataset.originalSrc = src;
// Avoid re-requesting while a previous hydrate is in flight for the same original.
if (img.dataset.hydrating === "1") return;
img.dataset.hydrating = "1";
try {
const dataUrl = await requestResource(src);
if (dataUrl) img.setAttribute("src", dataUrl);
} finally {
delete img.dataset.hydrating;
}
})
);
}
async function renderMermaidBlocks(root: HTMLElement, theme: "light" | "dark") {
const codeBlocks = Array.from(root.querySelectorAll("pre code.language-mermaid, pre code[class*='mermaid']"));
// Also treat fenced mermaid paragraphs produced as codeBlock with language attr via data
const preBlocks = Array.from(root.querySelectorAll("pre")).filter((pre) => {
const text = pre.textContent || "";
return pre.querySelector("code") && /^(graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|mindmap|flowchart)/m.test(text.trim());
});
const targets = new Set([...codeBlocks.map((c) => c.parentElement!).filter(Boolean), ...preBlocks]);
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: theme === "dark" ? "dark" : "default",
flowchart: { nodeSpacing: 25, rankSpacing: 30 },
});
let i = 0;
for (const pre of targets) {
const source = (pre.textContent || "").trim();
if (!source) continue;
try {
const id = `mmd-${Date.now()}-${i++}`;
const { svg } = await mermaid.render(id, source);
const wrap = document.createElement("div");
wrap.className = "edgeever-mermaid";
wrap.innerHTML = svg;
const renderedSvg = wrap.querySelector("svg");
const viewBox = renderedSvg?.viewBox.baseVal;
if (renderedSvg && viewBox && viewBox.width > 0 && viewBox.height > 0) {
renderedSvg.style.width = `${Math.ceil(viewBox.width)}px`;
renderedSvg.style.height = `${Math.ceil(viewBox.height)}px`;
renderedSvg.style.maxWidth = "none";
}
pre.replaceWith(wrap);
} catch {
// leave code block as-is
}
}
}
let viewerDiagramReader: ReturnType<typeof attachDiagramReader> | null = null;
let viewerDiagramFrame: number | null = null;
let viewerDiagram: DiagramDocument | null = null;
let viewerDiagramGraph: Graph | null = null;
let viewerDiagramObserver: ResizeObserver | null = null;
let viewerDiagramContainer: HTMLElement | null = null;
function clearViewerDiagramGraph() {
if (viewerDiagramFrame !== null) cancelAnimationFrame(viewerDiagramFrame);
viewerDiagramFrame = null;
viewerDiagramReader?.dispose();
viewerDiagramReader = null;
viewerDiagramObserver?.disconnect();
viewerDiagramObserver = null;
viewerDiagramGraph?.dispose();
viewerDiagramGraph = null;
viewerDiagramContainer?.remove();
viewerDiagramContainer = null;
editorEl.querySelector<HTMLElement>(".ProseMirror")?.removeAttribute("hidden");
}
function renderViewerDiagram(root: HTMLElement, diagram: DiagramDocument, theme: "light" | "dark") {
clearViewerDiagramGraph();
const proseMirror = root.querySelector<HTMLElement>(".ProseMirror");
if (!proseMirror) return false;
const cells = diagramDocumentToX6Cells(diagram, theme);
const container = document.createElement("div");
container.className = "edgeever-x6-diagram";
container.setAttribute("role", "img");
container.setAttribute("aria-label", diagram.kind === "mind-map" ? "思维导图" : diagram.kind === "architecture" ? "架构图" : "流程图");
// Keep TipTap's managed DOM intact. Replacing a node inside ProseMirror makes
// the next setContent call unreliable when SwiftUI reuses this WKWebView.
proseMirror.hidden = true;
root.append(container);
const parent = container.parentElement;
const measureWidth = () => {
if (!parent) return Math.max(1, container.clientWidth);
const style = getComputedStyle(parent);
const containerStyle = getComputedStyle(container);
const horizontalPadding = Number.parseFloat(style.paddingLeft || "0")
+ Number.parseFloat(style.paddingRight || "0");
const horizontalMargin = Number.parseFloat(containerStyle.marginLeft || "0")
+ Number.parseFloat(containerStyle.marginRight || "0");
return Math.max(1, parent.clientWidth - horizontalPadding - horizontalMargin);
};
const graph = new Graph({
container,
// The replacement node is empty at mount time and can briefly report 0.
// Anchor sizing to its stable parent so X6 cannot persist a 1px canvas.
width: measureWidth(),
height: Math.max(1, container.clientHeight),
background: { color: cells.canvas },
grid: false,
interacting: false,
panning: { enabled: true },
mousewheel: { enabled: true, minScale: 0.1, maxScale: 2.5 },
});
graph.addNodes(cells.nodes);
graph.addEdges(cells.edges);
const reader = attachDiagramReader(graph, container, { ...diagram, nodes: diagram.nodes.map((node, index) => ({ ...node, width: cells.nodes[index].width, height: cells.nodes[index].height })) }, locale, theme === "dark");
viewerDiagramReader = reader;
const fit = () => reader.resize(measureWidth(), Math.max(1, container.clientHeight));
viewerDiagramFrame = requestAnimationFrame(fit);
const observer = new ResizeObserver(fit);
observer.observe(parent ?? container);
viewerDiagramGraph = graph;
viewerDiagramObserver = observer;
viewerDiagramContainer = container;
return true;
}
const IMAGE_WIDTH_PRESETS = [
{ label: "25%", width: 25 },
{ label: "50%", width: 50 },
{ label: "75%", width: 75 },
{ label: "100%", width: 100 },
] as const;
function parseImageWidth(raw: unknown): number | null {
if (raw == null || raw === "") return null;
const n = typeof raw === "number" ? raw : Number.parseFloat(String(raw).replace("%", ""));
if (!Number.isFinite(n)) return null;
return Math.min(100, Math.max(10, Math.round(n)));
}
function clampImageWidth(n: number): number {
return Math.min(100, Math.max(10, Math.round(n)));
}
/**
* Image width is a % of the editor content column.
* Apply to the figure itself (not only the <img>) so the layout box shrinks
* with the picture — otherwise WKWebView keeps a full-width slot and paints
* a pale selection wash in the empty half.
*/
function applyFigureWidth(el: HTMLElement, width: number | null) {
const w = width == null ? null : clampImageWidth(width);
if (w == null) {
el.style.removeProperty("width");
el.style.removeProperty("max-width");
el.style.removeProperty("--ee-image-width");
delete el.dataset.width;
el.classList.remove("has-width");
return;
}
const pct = `${w}%`;
el.dataset.width = String(w);
el.classList.add("has-width");
el.style.setProperty("--ee-image-width", pct);
// Inline styles beat any generic img/figure rules; max-width keeps us inside the column.
el.style.setProperty("width", pct);
el.style.setProperty("max-width", pct);
el.style.setProperty("margin-left", "auto");
el.style.setProperty("margin-right", "auto");
el.style.setProperty("box-sizing", "border-box");
el.style.setProperty("display", "block");
}
/**
* iOS WKWebView + ProseMirror: control taps must:
* 1) preventDefault on pointerdown so PM does not steal NodeSelection
* 2) run the action on pointerup (click is suppressed after preventDefault)
* Desktop still gets a debounced click fallback.
*/
function bindImageControlTap(el: HTMLElement, action: () => void) {
let lastRun = 0;
const block = (event: Event) => {
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
};
const run = (event: Event) => {
block(event);
const now = Date.now();
if (now - lastRun < 350) return;
lastRun = now;
action();
};
el.addEventListener("pointerdown", block);
el.addEventListener("mousedown", block);
el.addEventListener("touchstart", block, { passive: false });
el.addEventListener("pointerup", run);
el.addEventListener("click", run);
}
/**
* Android LocalTiptapEditor image node parity:
* - figure wrapper + ⋯ action button
* - viewer: tap image → fullscreen preview; ⋯ → resource sheet
* - editor: tap image → select + show width bar (25/50/75/100%); ⋯ → resource sheet
*/
function createEdgeEverImageExtension() {
return Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (element) =>
parseImageWidth(
element.getAttribute("data-width") ?? element.getAttribute("width") ?? (element as HTMLElement).style.width
),
renderHTML: (attributes) => {
const width = parseImageWidth(attributes.width);
return width ? { "data-width": String(width), style: `width: ${width}%` } : {};
},
},
};
},
addNodeView() {
return ({ editor: ed, getPos, node }) => {
let currentNode = node;
const wrapper = document.createElement("figure");
wrapper.className = "edgeever-image-node is-loading";
wrapper.contentEditable = "false";
wrapper.setAttribute("role", "img");
const loading = document.createElement("div");
loading.className = "edgeever-image-loading";
const spinner = document.createElement("span");
spinner.className = "edgeever-image-upload-spinner";
loading.append(spinner);
const image = document.createElement("img");
image.draggable = false;
image.hidden = true;
const actionButton = document.createElement("button");
actionButton.type = "button";
actionButton.className = "edgeever-image-actions";
actionButton.contentEditable = "false";
actionButton.hidden = true;
actionButton.setAttribute("aria-label", "图片操作");
actionButton.textContent = "⋯";
const sizeControls = document.createElement("div");
sizeControls.className = "edgeever-image-size-controls";
sizeControls.contentEditable = "false";
sizeControls.hidden = true;
sizeControls.setAttribute("role", "group");
sizeControls.setAttribute("aria-label", "图片宽度");
const applyWidthAtPos = (width: number) => {
const pos = getPos();
if (typeof pos !== "number") return;
const n = ed.state.doc.nodeAt(pos);
if (!n || n.type.name !== "image") return;
const nextWidth = clampImageWidth(width);
// Avoid chain().focus() — iOS WKWebView often drops NodeSelection mid-gesture.
let tr = ed.state.tr.setNodeMarkup(pos, undefined, {
...n.attrs,
width: nextWidth,
});
try {
tr = tr.setSelection(NodeSelection.create(tr.doc, pos));
} catch {
/* keep markup update even if selection fails */
}
ed.view.dispatch(tr);
applyFigureWidth(wrapper, nextWidth);
setActiveWidth(nextWidth);
};
const sizeButtons: Array<{ button: HTMLButtonElement; width: number }> = [];
for (const preset of IMAGE_WIDTH_PRESETS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edgeever-image-size-button";
btn.textContent = preset.label;
btn.setAttribute("aria-label", preset.label);
btn.setAttribute("aria-pressed", "false");
bindImageControlTap(btn, () => applyWidthAtPos(preset.width));
sizeButtons.push({ button: btn, width: preset.width });
sizeControls.append(btn);
}
const setActiveWidth = (width: number | null) => {
for (const item of sizeButtons) {
const active = width != null && item.width === width;
item.button.classList.toggle("is-active", active);
item.button.setAttribute("aria-pressed", String(active));
}
};
const setPhase = (phase: "loading" | "ready" | "failed") => {
wrapper.classList.toggle("is-loading", phase === "loading");
wrapper.classList.toggle("is-failed", phase === "failed");
loading.hidden = phase === "ready";
image.hidden = phase !== "ready";
actionButton.hidden = phase !== "ready";
if (phase === "failed") {
loading.replaceChildren();
const label = document.createElement("span");
label.className = "edgeever-image-loading-label";
label.textContent = "图片加载失败";
loading.append(label);
loading.hidden = false;
} else if (phase === "loading") {
loading.replaceChildren(spinner);
}
};
const resourceHrefFromAttrs = (attrs: Record<string, unknown>) => {
const src = String(attrs.src ?? "");
// Prefer protected API path over display data:/edgeever-res:
if (isProtectedResource(src) || getResourceIdFromHref(src)) return src;
return wrapper.dataset.resourceHref || src;
};
const emitActions = () => {
const href =
wrapper.dataset.resourceHref ||
image.dataset.originalSrc ||
resourceHrefFromAttrs(currentNode.attrs as Record<string, unknown>);
const json = buildImageTargetJson(href, String(currentNode.attrs.alt ?? "image"));
if (json) {
post({ type: "resourcePress", targetJson: json });
return;
}
// Still notify native with best-effort payload when id parse fails.
post({
type: "log",
message: `image actions: no resource id for href=${href.slice(0, 120)}`,
});
};
const emitPreview = () => {
const href =
wrapper.dataset.resourceHref ||
image.dataset.originalSrc ||
resourceHrefFromAttrs(currentNode.attrs as Record<string, unknown>) ||
image.getAttribute("src") ||
"";
if (!href) return;
post({
type: "imagePreview",
source: href,
alt: String(currentNode.attrs.alt ?? ""),
});
};
bindImageControlTap(actionButton, emitActions);
// Android parity:
// - viewer: tap image → fullscreen preview
// - editor: tap image → select node (shows 25/50/75/100% width bar); ⋯ → action sheet
// Image body: only stopPropagation on pointerdown (keeps scroll working).
let lastImageActivate = 0;
const onImageActivate = (event: Event) => {
if (event.target instanceof Element && event.target.closest(".edgeever-image-actions")) {
return;
}
const now = Date.now();
if (now - lastImageActivate < 350) return;
lastImageActivate = now;
event.stopPropagation();
if (mode === "viewer" || !ed.isEditable) {
event.preventDefault();
emitPreview();
return;
}
event.preventDefault();
const pos = getPos();
if (typeof pos === "number") {
ed.chain().setNodeSelection(pos).run();
}
};
image.addEventListener("pointerdown", (event) => event.stopPropagation());
image.addEventListener("click", onImageActivate);
image.addEventListener("pointerup", (event) => {
// iOS sometimes skips click after PM handles pointer; activate on short taps.
if (event.pointerType === "touch" || event.pointerType === "pen") {
onImageActivate(event);
}
});
wrapper.append(loading, image, actionButton, sizeControls);
let requestId = 0;
let renderedSource = "";
let selected = false;
const applyMeta = (attrs: Record<string, unknown>) => {
const alt = String(attrs.alt ?? "");
image.alt = alt;
const width = parseImageWidth(attrs.width);
applyFigureWidth(wrapper, width);
setActiveWidth(width);
const src = String(attrs.src ?? "");
const rid = getResourceIdFromHref(src);
if (rid) {
wrapper.dataset.resourceHref = normalizeResourceHref(src, rid);
image.dataset.originalSrc = wrapper.dataset.resourceHref;
}
};
const loadImage = (attrs: Record<string, unknown>) => {
requestId += 1;
const active = requestId;
const src = String(attrs.src ?? "");
renderedSource = src;
applyMeta(attrs);
setPhase("loading");
sizeControls.hidden = true;
const finish = (display: string) => {
if (active !== requestId) return;
image.onload = () => {
if (active !== requestId) return;
setPhase("ready");
image.style.cursor = mode === "viewer" || !ed.isEditable ? "zoom-in" : "pointer";
if (selected && ed.isEditable && mode === "editor") {
sizeControls.hidden = false;
}
};
image.onerror = () => {
if (active !== requestId) return;
setPhase("failed");
};
image.src = display;
};
if (!src) {
setPhase("failed");
return;
}
if (src.startsWith("data:") || src.startsWith("edgeever-res:") || src.startsWith("blob:")) {
finish(src);
return;
}
if (needsNativeHydration(src)) {
void requestResource(src).then((dataUrl) => {
if (active !== requestId) return;
if (dataUrl) finish(dataUrl);
else setPhase("failed");
});
return;
}
finish(src);
};
loadImage(node.attrs as Record<string, unknown>);
return {
dom: wrapper,
update: (updated) => {
if (updated.type.name !== "image") return false;
currentNode = updated;
const nextSrc = String(updated.attrs.src ?? "");
applyMeta(updated.attrs as Record<string, unknown>);
if (nextSrc !== renderedSource) {
loadImage(updated.attrs as Record<string, unknown>);
} else {
setActiveWidth(parseImageWidth(updated.attrs.width));
applyFigureWidth(wrapper, parseImageWidth(updated.attrs.width));
}
return true;
},
selectNode: () => {
selected = true;
wrapper.classList.add("is-selected");
// Re-assert layout width so selection never leaves a full-width empty slot.
const selWidth =
parseImageWidth(
(ed.state.selection as { node?: { attrs?: { width?: unknown } } }).node?.attrs?.width
) ?? parseImageWidth(wrapper.dataset.width) ?? parseImageWidth(currentNode.attrs.width);
applyFigureWidth(wrapper, selWidth);
// Width bar only in editor when the image is the active node selection.
if (ed.isEditable && mode === "editor" && !image.hidden) {
sizeControls.hidden = false;
setActiveWidth(selWidth);
}
},
deselectNode: () => {
selected = false;
wrapper.classList.remove("is-selected");
sizeControls.hidden = true;
},
destroy: () => {
requestId += 1;
},
};
};
},
}).configure({
inline: false,
allowBase64: true,
});
}
function buildExtensions(placeholder: string) {
return [
...createEdgeEverDocumentExtensions({
mathematics: createEdgeEverMathematics(),
starterKit: { codeBlock: false, link: false },
image: createEdgeEverImageExtension(),
gallery: createIosImageGallery(() => locale),
pdf: false,
file: false,
pluginEmbed: false,
table: { table: { resizable: false } },
markdown: true,
}),
NativeAttachmentMetadata,
CodeBlock.configure({
languageClassPrefix: "language-",
}),
...createNativeUnsupportedContentExtensions(),
Placeholder.configure({
placeholder,
}),
];
}
const editorEl = document.getElementById("editor")!;
const toolbarEl = document.getElementById("toolbar")!;
let readerCollapsed = false;
editorEl.addEventListener("scroll", () => {
if (mode !== "viewer") return;
const collapsed = editorEl.scrollTop > (readerCollapsed ? 4 : 24);
if (collapsed === readerCollapsed) return;
readerCollapsed = collapsed;
post({ type: "readerScroll", collapsed });
}, { passive: true });
function resetReaderScroll() {
if (mode !== "viewer") return;
editorEl.scrollTop = 0;
readerCollapsed = false;
post({ type: "readerScroll", collapsed: false });
}
const editor = new Editor({
element: editorEl,
extensions: buildExtensions("开始书写…"),
editable: false,
content: { type: "doc", content: [{ type: "paragraph" }] },
onUpdate: ({ editor: ed }) => {
refreshToolbarState();
requestAnimationFrame(() => decorateAttachmentLinks(editorEl));
if (suppressChange || mode !== "editor") return;
emitChange(ed);
},
onSelectionUpdate: () => refreshToolbarState(),
editorProps: {
attributes: {
class: "edgeever-prose",
spellcheck: "true",
},
transformPastedHTML(html) {
return wrapDetailsContentHtml(html);
},
handleClick(_view, _pos, event) {
return handleResourcePointer(event as MouseEvent, "click");
},
handleDOMEvents: {
contextmenu(_view, event) {
return handleResourcePointer(event, "contextmenu");
},
},
},
});
// Evernote-style viewer shortcut: a deliberate double tap on ordinary note
// content enters edit mode. Resource controls keep their existing gestures.
editorEl.addEventListener("dblclick", (event) => {
if (mode !== "viewer") return;
const target = event.target as HTMLElement | null;
if (!target || target.closest("a, button, img, input, textarea, select, .edgeever-image-node")) return;
event.preventDefault();
event.stopPropagation();
post({ type: "doubleTap" });
});
/**
* Android parity:
* - attachment link → always resource action sheet
* - viewer image click → fullscreen preview
* - viewer image long-press / contextmenu → resource action sheet
* - editor image click inside nodeView → select + width bar (owned by nodeView)
* - editor image ⋯ → resource action sheet (owned by nodeView)
*/
function handleResourcePointer(event: Event, kind: "click" | "contextmenu"): boolean {
const target = event.target as HTMLElement | null;
if (!target) return false;
// Size / ⋯ controls own their gestures — never intercept from PM handleClick.
if (
target.closest(".edgeever-image-size-controls") ||
target.closest(".edgeever-image-actions") ||
target.closest(".edgeever-image-size-button")
) {
return true;
}
const link = target.closest("a");
if (link instanceof HTMLAnchorElement) {
const href = link.getAttribute("href") || "";
if (isProtectedResource(href) || getResourceIdFromHref(href)) {
const json = buildAttachmentTargetJson(href, link.textContent || "");
if (json) {
event.preventDefault();
event.stopPropagation();
post({ type: "resourcePress", targetJson: json });
return true;
}
}
}
const img = target.closest("img");
if (img instanceof HTMLImageElement) {
// Custom image nodeView owns image taps (select/preview/⋯).
if (img.closest("figure.edgeever-image-node") && kind === "click") {
return false;
}
const src = img.dataset.originalSrc || img.getAttribute("src") || "";
const protectedSrc =
img.dataset.originalSrc ||
(isProtectedResource(src) || getResourceIdFromHref(src) ? src : "");
if (!protectedSrc && !getResourceIdFromHref(src)) {
// Non-protected image — still allow preview of data: display src in viewer.
if (mode === "viewer" && kind === "click" && src) {
event.preventDefault();
post({
type: "imagePreview",
source: src,
alt: img.getAttribute("alt") || "",
});
return true;
}
return false;
}
const hrefForMenu = protectedSrc || src;
const filename = img.getAttribute("alt") || "image";
// Long-press / context menu → resource actions (editor + viewer).
if (kind === "contextmenu") {
const json = buildImageTargetJson(hrefForMenu, filename);
if (json) {
event.preventDefault();
event.stopPropagation();
post({ type: "resourcePress", targetJson: json });
return true;
}
}
// Viewer plain tap on bare <img> (no nodeView) → preview.
if (mode === "viewer" && kind === "click") {
event.preventDefault();
event.stopPropagation();
post({
type: "imagePreview",
source: hrefForMenu,
alt: filename,
});
return true;
}
}
return false;
}
// Long-press on images (mobile WebView often does not fire contextmenu reliably).
(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
let startImg: HTMLImageElement | null = null;
const clear = () => {
if (timer) clearTimeout(timer);
timer = null;
startImg = null;
};
editorEl.addEventListener(
"touchstart",
(event) => {
const img = (event.target as HTMLElement | null)?.closest("img");
if (!(img instanceof HTMLImageElement)) return;
startImg = img;
timer = setTimeout(() => {
if (!startImg) return;
const src = startImg.dataset.originalSrc || startImg.getAttribute("src") || "";
const json = buildImageTargetJson(src, startImg.getAttribute("alt") || "image");
if (json) {
post({ type: "resourcePress", targetJson: json });
}
clear();
}, 480);
},
{ passive: true }
);
editorEl.addEventListener("touchend", clear, { passive: true });
editorEl.addEventListener("touchmove", clear, { passive: true });
editorEl.addEventListener("touchcancel", clear, { passive: true });
})();
const LITERAL_DOLLAR_PLACEHOLDER = "\uE000edgeever-dollar\uE001";
const protectLiteralDollarPairs = (value: unknown): unknown => {
if (!value || typeof value !== "object") return value;
const node = value as { type?: unknown; text?: unknown; content?: unknown };
if (node.type === "text" && typeof node.text === "string") {
const dollarCount = Array.from(node.text).filter((character) => character === "$").length;
return dollarCount >= 2
? { ...node, text: node.text.replaceAll("$", LITERAL_DOLLAR_PLACEHOLDER) }
: value;
}
return Array.isArray(node.content)
? { ...node, content: node.content.map(protectLiteralDollarPairs) }
: value;
};
const serializeEditorMarkdown = (ed: Editor) => {
return docToMarkdown(restoreNativeEditorContent(ed.getJSON() as TiptapDoc));
};
let pendingAiSelection: { from: number; to: number; isInline: boolean; documentFingerprint: string } | null = null;
const serializeSelectionMarkdown = (ed: Editor, from: number, to: number) => {
const manager = (ed.storage as { markdown?: { manager?: { serialize?: (doc: unknown) => string } } })
.markdown?.manager;
const content = ed.state.doc.slice(from, to).content.toJSON();
if (manager?.serialize) {
return manager
.serialize(protectLiteralDollarPairs({ type: "doc", content }))
.replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
}
return ed.state.doc.textBetween(from, to, "\n\n");
};
type AiSelectionContext = {
from: number;
to: number;
isInline: boolean;
markdown: string;
text: string;
};
type ParsedMarkdownNode = {
type?: string;
text?: string;
content?: ParsedMarkdownNode[];
[key: string]: unknown;
};
const AI_INLINE_SENTINEL = "edgeever-inline-sentinel";
const serializeInlineSelectionMarkdown = (ed: Editor, content: unknown[], fallback: string) => {
const manager = (ed.storage as { markdown?: { manager?: { serialize?: (doc: unknown) => string } } })
.markdown?.manager;
if (!manager?.serialize) return fallback;
return manager
.serialize(protectLiteralDollarPairs({ type: "doc", content: [{ type: "paragraph", content }] }))
.replaceAll(LITERAL_DOLLAR_PLACEHOLDER, "\\$");
};
const getAiSelectionContext = (ed: Editor): AiSelectionContext | null => {
const selection = ed.state.selection;
if (selection.empty || selection.from >= selection.to) return null;
const selectedTextblocks: Array<{
node: ProseMirrorNode;
contentFrom: number;
contentTo: number;
from: number;
to: number;
}> = [];
ed.state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
if (!node.isTextblock) return true;
const contentFrom = pos + 1;
const contentTo = contentFrom + node.content.size;
const from = Math.max(selection.from, contentFrom);
const to = Math.min(selection.to, contentTo);
if (to > from) selectedTextblocks.push({ node, contentFrom, contentTo, from, to });
return false;
});
if (selectedTextblocks.length === 1) {
const block = selectedTextblocks[0];
const selectedBlock = block.node.cut(
block.from - block.contentFrom,
block.to - block.contentFrom,
).toJSON() as { content?: unknown[] };
const text = ed.state.doc.textBetween(block.from, block.to, "\n");
const markdown = serializeInlineSelectionMarkdown(ed, selectedBlock.content ?? [], text).trim();
return markdown
? { from: block.from, to: block.to, isInline: true, markdown, text }
: null;
}
const markdown = serializeSelectionMarkdown(ed, selection.from, selection.to).trim();
return markdown
? {
from: selection.from,
to: selection.to,
isInline: false,
markdown,
text: ed.state.doc.textBetween(selection.from, selection.to, "\n\n"),
}
: null;
};
const parseAiSelectionReplacement = (ed: Editor, draft: string, isInline: boolean): unknown[] => {
const manager = (ed.storage as { markdown?: { manager?: { parse?: (value: string) => { content?: unknown[] } } } })
.markdown?.manager;
const normalizedDraft = draft.trim();
const blockContent = manager?.parse?.(normalizedDraft).content ?? [{ type: "text", text: normalizedDraft }];
if (!isInline) return blockContent;
const inlineDraft = normalizedDraft.replace(/\s*\n+\s*/g, " ");
const inlineContent = manager?.parse?.(`${AI_INLINE_SENTINEL}${inlineDraft}`).content;
const paragraph = inlineContent?.length === 1 ? inlineContent[0] as ParsedMarkdownNode : null;
const paragraphContent = paragraph?.type === "paragraph" ? paragraph.content ?? [] : [];
const firstNode = paragraphContent[0];
if (firstNode?.type !== "text" || typeof firstNode.text !== "string" || !firstNode.text.startsWith(AI_INLINE_SENTINEL)) {
return [{ type: "text", text: inlineDraft }];
}
const firstText = firstNode.text.slice(AI_INLINE_SENTINEL.length);
return [
...(firstText ? [{ ...firstNode, text: firstText }] : []),
...paragraphContent.slice(1),
];
};
function emitChange(ed: Editor) {
try {
const contentJson = JSON.stringify(restoreNativeEditorContent(ed.getJSON() as TiptapDoc));
const contentMarkdown = serializeEditorMarkdown(ed);
post({ type: "change", contentMarkdown, contentJson });
} catch (error) {
post({ type: "error", message: error instanceof Error ? error.message : String(error) });
}
}
type EditorSearchMatch = { from: number; to: number };
function getEditorSearchMatches(ed: Editor, query: string): EditorSearchMatch[] {
const needle = query.trim().toLocaleLowerCase();
if (needle.length === 0) return [];
const characters: Array<{ char: string; pos: number }> = [];
let previousTextEnd: number | null = null;
ed.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
if (previousTextEnd !== null && pos > previousTextEnd) {
characters.push({ char: "\u0000", pos: -1 });
}
for (let index = 0; index < node.text.length; index += 1) {
characters.push({ char: node.text[index] ?? "", pos: pos + index });
}
previousTextEnd = pos + node.text.length;
});
const haystack = characters.map((item) => item.char).join("").toLocaleLowerCase();
const matches: EditorSearchMatch[] = [];
let index = haystack.indexOf(needle);
while (index !== -1) {
const start = characters[index];
const end = characters[index + needle.length - 1];
if (start && end && start.pos >= 0 && end.pos >= 0) {
matches.push({ from: start.pos, to: end.pos + 1 });
}
index = haystack.indexOf(needle, index + needle.length);
}
return matches;
}
const activeListItemType = () => editor.isActive("taskItem") ? "taskItem" : "listItem";
function setToolbarVisible(visible: boolean) {
toolbarEl.classList.toggle("editor-mode", visible);
toolbarEl.innerHTML = "";
if (!visible) return;
const actions: Array<{ id: string; label: string; run: () => void }> = [
{ id: "undo", label: "↩", run: () => editor.chain().focus().undo().run() },
{ id: "redo", label: "↪", run: () => editor.chain().focus().redo().run() },
{
id: "image",
label: "▧+",
run: () => post({ type: "pickImage" }),
},
{ id: "bold", label: "B", run: () => editor.chain().focus().toggleBold().run() },
{
id: "bullet",
label: "•",
run: () => editor.chain().focus().toggleBulletList().run(),
},
{
id: "task",
label: "☑",
run: () => editor.chain().focus().toggleTaskList().run(),
},
{
id: "indent",
label: "⇥",
run: () => editor.chain().focus().sinkListItem(activeListItemType()).run(),
},
{
id: "outdent",
label: "⇤",
run: () => editor.chain().focus().liftListItem(activeListItemType()).run(),
},
{
id: "quote",
label: "❝",
run: () => editor.chain().focus().toggleBlockquote().run(),
},
{
id: "hr",
label: "—",
run: () => editor.chain().focus().setHorizontalRule().run(),
},
];
for (const action of actions) {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = action.label;
btn.dataset.action = action.id;
const labels: Record<string, Record<MobileEditorLocale, string>> = {
undo: { "zh-CN": "撤销", "en-US": "Undo", ja: "元に戻す", pl: "Cofnij" },
redo: { "zh-CN": "重做", "en-US": "Redo", ja: "やり直す", pl: "Ponów" },
image: { "zh-CN": "插入图片", "en-US": "Insert image", ja: "画像を挿入", pl: "Wstaw obraz" },
bold: { "zh-CN": "粗体", "en-US": "Bold", ja: "太字", pl: "Pogrubienie" },
bullet: { "zh-CN": "项目符号列表", "en-US": "Bullet list", ja: "箇条書き", pl: "Lista punktowana" },
task: { "zh-CN": "任务清单", "en-US": "Task list", ja: "タスクリスト", pl: "Lista zadań" },
indent: { "zh-CN": "增加列表缩进", "en-US": "Increase list indent", ja: "リストのインデントを増やす", pl: "Zwiększ wcięcie listy" },
outdent: { "zh-CN": "减少列表缩进", "en-US": "Decrease list indent", ja: "リストのインデントを減らす", pl: "Zmniejsz wcięcie listy" },
quote: { "zh-CN": "引用", "en-US": "Block quote", ja: "引用", pl: "Cytat" },
hr: { "zh-CN": "分隔线", "en-US": "Horizontal rule", ja: "区切り線", pl: "Linia pozioma" },
};
btn.setAttribute("aria-label", labels[action.id]?.[locale] ?? action.id);
btn.addEventListener("click", () => {
action.run();
if (action.id !== "image") emitChange(editor);
refreshToolbarState();
});
toolbarEl.appendChild(btn);
}
refreshToolbarState();
}
function refreshToolbarState() {
const active: Record<string, boolean> = {
bold: editor.isActive("bold"),
bullet: editor.isActive("bulletList"),
task: editor.isActive("taskList"),
quote: editor.isActive("blockquote"),
};
const historyEnabled = (command: "undo" | "redo") => {
try {
return editor.can().chain().focus()[command]().run();
} catch {
return false;
}
};
const enabled: Record<string, boolean> = {
undo: historyEnabled("undo"),
redo: historyEnabled("redo"),
};
toolbarEl.querySelectorAll<HTMLButtonElement>("button[data-action]").forEach((button) => {
const actionId = button.dataset.action ?? "";
button.classList.toggle("is-active", active[actionId] ?? false);
if (actionId in enabled) button.disabled = !enabled[actionId];
});
}
async function afterContentSet(theme: "light" | "dark" = "light") {
editorEl.querySelectorAll<HTMLElement>("[data-placeholder]").forEach((element) => {
element.dataset.placeholder = currentPlaceholder;
});
decorateAttachmentLinks(editorEl);
await hydrateProtectedImages(editorEl);
if (mode === "viewer") {
if (!viewerDiagram || !renderViewerDiagram(editorEl, viewerDiagram, theme)) {
clearViewerDiagramGraph();
await renderMermaidBlocks(editorEl, theme);
}
}
}
async function exportNoteImage(request: ImageExportRequest) {
if (!request.requestId || (request.format !== "png" && request.format !== "jpeg")) return;
const resolvedTheme = resolveTheme(request.background, request.theme);
const fontStyle = request.fontStyle ?? "serif";
const fontSize = request.fontSize ?? "lg";
const cardWidth = request.cardWidth ?? "wide";
const targetWidth = NOTE_IMAGE_CARD_WIDTH_PIXELS[cardWidth] || NOTE_IMAGE_CARD_WIDTH_PIXELS.wide;
const themeCfg = NOTE_IMAGE_THEMES[resolvedTheme] || NOTE_IMAGE_THEMES.aurora;
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();
});
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 {
const images = Array.from(documentRoot.querySelectorAll<HTMLImageElement>("img"));
await Promise.race([
Promise.all([
document.fonts?.ready,
...images.map(async (image) => {
if (image.complete) return;
try { await image.decode(); } catch { /* Export the readable remainder. */ }
}),
]),
new Promise<void>((resolve) => window.setTimeout(resolve, 8_000)),
]);
const exportedImages = Array.from(
documentRoot.querySelectorAll<HTMLImageElement>(".edgeever-card-body img"),
);
const failedImages = exportedImages.filter((image) => !image.complete || image.naturalWidth === 0).length;
const totalHeight = Math.max(1, Math.ceil(documentRoot.getBoundingClientRect().height));
const renderPlan = planNativeNoteImageRender(targetWidth, totalHeight);
let embedFailedImages = 0;
let captureRoot = documentRoot;
let captureWrapper: HTMLDivElement | null = null;
if (renderPlan.sourceScale < 1) {
captureWrapper = document.createElement("div");
captureWrapper.style.cssText = `position:relative;width:${renderPlan.sourceWidth}px;height:${renderPlan.sourceHeight}px;overflow:hidden;`;
documentRoot.replaceWith(captureWrapper);
captureWrapper.appendChild(documentRoot);
documentRoot.style.transform = `scale(${renderPlan.sourceScale})`;
documentRoot.style.transformOrigin = "top left";
captureRoot = captureWrapper;
}
const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg;
const missingImagePlaceholder = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
const renderCanvas = (root: HTMLElement, width: number, height: number) => toCanvas(root, {
backgroundColor,
cacheBust: false,
height,
imagePlaceholder: missingImagePlaceholder,
onImageErrorHandler: (event) => {
embedFailedImages += 1;
if (event instanceof Event && event.target instanceof HTMLImageElement) {
event.target.src = missingImagePlaceholder;
}
},
pixelRatio: renderPlan.pixelRatio,
skipFonts: true,
width,
});
let canvas: HTMLCanvasElement;
try {
canvas = await renderCanvas(captureRoot, renderPlan.sourceWidth, renderPlan.sourceHeight);
} catch {
const retryScale = renderPlan.sourceScale * 0.75;
if (retryScale < 0.5) throw new Error("NOTE_IMAGE_RENDER_FAILED");
const retryWidth = Math.max(1, Math.floor(targetWidth * retryScale));
const retryHeight = Math.max(1, Math.floor(totalHeight * retryScale));
if (!captureWrapper) {
captureWrapper = document.createElement("div");
documentRoot.replaceWith(captureWrapper);
captureWrapper.appendChild(documentRoot);
}
captureWrapper.style.cssText = `position:relative;width:${retryWidth}px;height:${retryHeight}px;overflow:hidden;`;
documentRoot.style.transform = `scale(${retryScale})`;
documentRoot.style.transformOrigin = "top left";
try {
canvas = await renderCanvas(captureWrapper, retryWidth, retryHeight);
} catch {
throw new Error("NOTE_IMAGE_RENDER_FAILED");
}
}
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.92 : 1,
);
});
const extension = request.format === "jpeg" ? "jpg" : "png";
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";
const base64 = bytesToBase64(bytes);
for (let offset = 0; offset < base64.length; offset += IMAGE_EXPORT_CHUNK_SIZE) {
post({ type: "imageExportChunk", requestId: request.requestId, chunk: base64.slice(offset, offset + IMAGE_EXPORT_CHUNK_SIZE) });
await new Promise((resolve) => setTimeout(resolve, 0));
}
post({
type: "imageExportComplete",
requestId: request.requestId,
filename,
mimeType,
width: canvas.width,
height: canvas.height,
totalImages: exportedImages.length,
failedImages: Math.min(exportedImages.length, failedImages + embedFailedImages),
});
} catch (error) {
post({ type: "imageExportError", requestId: request.requestId, message: error instanceof Error ? error.message : "Image export failed" });
} finally {
host.remove();
}
}
export type EdgeEverEditorAPI = {
configure: (opts: ConfigureOptions) => void;
setMarkdown: (md: string) => void;
setDocumentFromJSON: (json: string) => void;
resetReaderScroll: () => void;
resolveResource: (requestId: string, dataUrl: string | null) => void;
getMarkdown: () => string;
getDocument: () => string;
captureSelection: () => string | null;
applySelectionDraft: (markdown: string, mode: "append" | "replace") => boolean;
undo: () => boolean;
focusEnd: () => void;
flush: () => void;
exec: (actionId: string) => void;
beginImageUpload: (uploadId: string, previewDataUrl: string) => void;
completeImageUpload: (uploadId: string, imageUrl: string, alt: string) => void;
groupImages: (sources: string[]) => boolean;
cancelImageUpload: (uploadId: string) => void;
search: (query: string, requestedIndex: number) => void;
exportImage: (request: ImageExportRequest) => void;
};
const api: EdgeEverEditorAPI = {
resetReaderScroll,
exportImage(request) {
void exportNoteImage(request);
},
configure(opts) {
const nextMode = opts.mode === "editor" ? "editor" : "viewer";
const modeChanged = nextMode !== mode;
mode = nextMode;
locale = opts.locale === "en-US" || opts.locale === "ja" || opts.locale === "pl" ? opts.locale : "zh-CN";
editor.setEditable(mode === "editor");
setToolbarVisible(mode === "editor");
document.documentElement.dataset.theme = opts.theme || "light";
document.body.classList.toggle("viewer-mode", mode === "viewer");
document.body.classList.toggle("editor-mode", mode === "editor");
if (opts.placeholder) {
currentPlaceholder = opts.placeholder;
editorEl.querySelectorAll<HTMLElement>("[data-placeholder]").forEach((element) => {
element.dataset.placeholder = currentPlaceholder;
});
}
// Match Evernote-style edit entry: focus the surface when entering editor mode.
// Combined with setContent's default end selection, caret lands at document end.
if (mode === "editor" && modeChanged) {
requestAnimationFrame(() => {
try {
editor.commands.focus("end");
} catch {
/* ignore */
}
});
}
void afterContentSet(opts.theme || "light");
},
setMarkdown(md) {
suppressChange = true;
const diagram = mode === "viewer" ? parseDiagramDocument(md) : null;
viewerDiagram = diagram;
// Valid IR is drawn by read-only X6. Do not inject a hidden Mermaid
// document into TipTap; invalid envelopes keep the stripped fence.
const displayMarkdown = mode === "viewer"
? (diagram ? "" : stripDiagramDocumentMarker(md))
: md;
try {
editor.commands.setContent(displayMarkdown || "", { contentType: "markdown" } as never);
} catch {
try {
const manager = (editor.storage as { markdown?: { manager?: { parse: (s: string) => unknown } } }).markdown
?.manager;
if (manager) {
editor.commands.setContent(manager.parse(displayMarkdown || "") as never);
} else {
throw new Error("no markdown manager");
}
} catch {
editor.commands.setContent({
type: "doc",
content: [{ type: "paragraph", content: displayMarkdown ? [{ type: "text", text: displayMarkdown }] : [] }],
});
}
}
// Keep editability. Do NOT focus("end") here — native re-pushes content on SwiftUI
// updates while typing; focusing would yank the caret to the document bottom mid-edit.
editor.setEditable(mode === "editor");
clearMobileEditorUndoHistory(editor);
refreshToolbarState();
suppressChange = false;
void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light");
},
setDocumentFromJSON(json) {
suppressChange = true;
viewerDiagram = null;
clearViewerDiagramGraph();
try {
const doc = JSON.parse(json) as TiptapDoc;
editor.commands.setContent(prepareNativeEditorContent(
resolveNativeAttachmentContent(doc),
locale,
));
} catch {
editor.commands.setContent({ type: "doc", content: [{ type: "paragraph" }] });
}
editor.setEditable(mode === "editor");
clearMobileEditorUndoHistory(editor);
refreshToolbarState();
suppressChange = false;
void afterContentSet((document.documentElement.dataset.theme as "light" | "dark") || "light");
},
resolveResource(requestId, dataUrl) {
const resolver = resourceResolvers.get(requestId);
if (resolver) {
resourceResolvers.delete(requestId);
resolver(dataUrl);
}
},
getMarkdown() {
return serializeEditorMarkdown(editor);
},
getDocument() {
return JSON.stringify(restoreNativeEditorContent(editor.getJSON() as TiptapDoc));
},
captureSelection() {
const context = getAiSelectionContext(editor);
if (!context) {
pendingAiSelection = null;
return null;
}
pendingAiSelection = {
from: context.from,
to: context.to,
isInline: context.isInline,
documentFingerprint: JSON.stringify(editor.getJSON()),
};
return JSON.stringify({
from: context.from,
to: context.to,
markdown: context.markdown,
text: context.text,
});
},
applySelectionDraft(markdown, applyMode) {
const range = pendingAiSelection;
if (!range || !markdown.trim()) return false;
if (JSON.stringify(editor.getJSON()) !== range.documentFingerprint) {
pendingAiSelection = null;
return false;
}
const docSize = editor.state.doc.content.size;
const from = Math.min(Math.max(range.from, 0), docSize);
const to = Math.min(Math.max(range.to, from), docSize);
try {
const manager = (editor.storage as { markdown?: { manager?: { parse?: (value: string) => { content?: unknown[] } } } })
.markdown?.manager;
const parsed = manager?.parse?.(markdown);
const content = applyMode === "replace"
? parseAiSelectionReplacement(editor, markdown, range.isInline)
: parsed?.content ?? markdown;
const insertRange = applyMode === "append" ? { from: to, to } : { from, to };
editor.chain().focus().insertContentAt(insertRange, content as never).run();
pendingAiSelection = null;
emitChange(editor);
return true;
} catch {
return false;
}
},
undo() {
if (!editor.can().undo()) return false;
const changed = editor.commands.undo();
if (changed) emitChange(editor);
return changed;
},
focusEnd() {
try {
editor.commands.focus("end");
} catch {
/* ignore */
}
// iOS WKWebView: TipTap selection alone may not move DOM focus to the contenteditable.
// Explicitly focus ProseMirror so the software keyboard can attach after native first-responder.
try {
const dom = editor.view?.dom as HTMLElement | undefined;
if (dom && typeof dom.focus === "function") {
dom.focus({ preventScroll: true });
}
} catch {
/* ignore */
}
},
flush() {
emitChange(editor);
},
exec(actionId) {
const map: Record<string, () => void> = {
bold: () => editor.chain().focus().toggleBold().run(),
bulletList: () => editor.chain().focus().toggleBulletList().run(),
taskList: () => editor.chain().focus().toggleTaskList().run(),
blockquote: () => editor.chain().focus().toggleBlockquote().run(),
horizontalRule: () => editor.chain().focus().setHorizontalRule().run(),
heading2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
codeBlock: () => editor.chain().focus().toggleCodeBlock().run(),
};
map[actionId]?.();
emitChange(editor);
},
beginImageUpload(uploadId, previewDataUrl) {
editor
.chain()
.focus()
.setImage({ src: previewDataUrl, alt: uploadId, width: NEW_IMAGE_WIDTH_PERCENT })
.run();
// mark last image
const imgs = editorEl.querySelectorAll("img");
const last = imgs[imgs.length - 1] as HTMLImageElement | undefined;
if (last) last.dataset.uploadId = uploadId;
emitChange(editor);
},
completeImageUpload(uploadId, imageUrl, alt) {
if (!editor.isEditable) return;
editor.view.dispatch(createImageInsertTransaction(editor.state, {
src: imageUrl, alt: alt || uploadId || "",
width: NEW_IMAGE_WIDTH_PERCENT,
}));
emitChange(editor);
},
groupImages(sources) {
const grouped = groupUploadedImages(editor, sources);
if (grouped) emitChange(editor);
return grouped;
},
cancelImageUpload(uploadId) {
const img = editorEl.querySelector(`img[data-upload-id="${uploadId}"]`);
img?.remove();
emitChange(editor);
},
search(query, requestedIndex) {
const matches = getEditorSearchMatches(editor, query);
const index = matches.length > 0
? Math.min(Math.max(Number.isFinite(requestedIndex) ? requestedIndex : 0, 0), matches.length - 1)
: 0;
const match = matches[index];
if (match) {
editor.commands.setTextSelection({ from: match.from, to: match.to });
try {
const dom = editor.view.domAtPos(match.from).node;
const element = dom instanceof Element ? dom : dom.parentElement;
element?.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
/* ignore */
}
}
post({ type: "searchResult", count: matches.length, index });
},
};
(window as unknown as { EdgeEverEditor: EdgeEverEditorAPI }).EdgeEverEditor = api;
setToolbarVisible(false);
post({ type: "ready", startupMs: Math.round(performance.now() - startedAt) });