feat: add image alignment options and enhance image handling in ArticleEditorCanvas

This commit is contained in:
2026-04-05 23:41:58 +08:00
parent b08be076b2
commit fe124b0ba4
8 changed files with 821 additions and 28 deletions
@@ -0,0 +1,599 @@
import type { Editor } from "@milkdown/kit/core";
import { imageBlockConfig } from "@milkdown/kit/component/image-block";
import { inlineImageConfig, imageInlineComponent } from "@milkdown/kit/component/image-inline";
import { $nodeSchema, $remark, $view } from "@milkdown/kit/utils";
import { CrepeFeature, useCrepeFeatures } from "@milkdown/crepe";
import DOMPurify from "dompurify";
export type RichImageAlign = "left" | "center" | "right";
type RichImageBlockFeatureConfig = {
inlineUploadButton?: string;
inlineImageIcon?: string;
inlineConfirmButton?: string;
inlineUploadPlaceholderText?: string;
inlineOnUpload?: (file: File) => Promise<string>;
blockUploadButton?: string;
blockImageIcon?: string;
blockCaptionIcon?: string;
blockConfirmButton?: string;
blockCaptionPlaceholderText?: string;
blockUploadPlaceholderText?: string;
blockOnUpload?: (file: File) => Promise<string>;
onUpload?: (file: File) => Promise<string>;
proxyDomURL?: (url: string) => Promise<string> | string;
onImageLoadError?: (event: Event) => void | Promise<void>;
maxWidth?: number;
maxHeight?: number;
};
type ParsedImageMarkup = {
src: string;
caption: string;
width: number;
height: number;
ratio: number;
align: RichImageAlign;
};
type RichImageBlockNode = {
type: "image-block";
url: string;
title: string;
alt: string;
width: number;
height: number;
ratio: number;
align: RichImageAlign;
};
const IMAGE_DATA_TYPE = "image-block";
function clampImageAlign(value: unknown): RichImageAlign {
const normalized = String(value ?? "").trim().toLowerCase();
if (normalized === "left" || normalized === "right") {
return normalized;
}
return "center";
}
function parsePositiveNumber(value: unknown): number {
const parsed = Number.parseFloat(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed <= 0) {
return 0;
}
return Number(parsed.toFixed(2));
}
function parseRatio(value: unknown): number {
const parsed = Number.parseFloat(String(value ?? "").trim());
if (!Number.isFinite(parsed) || parsed <= 0) {
return 1;
}
return Number(parsed.toFixed(2));
}
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function resolveAlignFromClassName(value: string): RichImageAlign | null {
if (value.includes("article-editor-image--left")) {
return "left";
}
if (value.includes("article-editor-image--right")) {
return "right";
}
if (value.includes("article-editor-image--center")) {
return "center";
}
return null;
}
function parseImageMarkup(markup: string): ParsedImageMarkup | null {
if (typeof DOMParser === "undefined") {
return null;
}
const doc = new DOMParser().parseFromString(markup, "text/html");
const image = doc.body.querySelector("img");
if (!(image instanceof HTMLImageElement)) {
return null;
}
const src = image.getAttribute("src")?.trim() ?? "";
if (!src) {
return null;
}
const container = image.closest("p, figure, div");
const align =
resolveAlignFromClassName(`${container?.className ?? ""} ${image.className ?? ""}`) ??
clampImageAlign(container?.getAttribute("align") || image.getAttribute("align"));
return {
src,
caption: (image.getAttribute("title") || image.getAttribute("alt") || "").trim(),
width: parsePositiveNumber(image.getAttribute("width")),
height: parsePositiveNumber(image.getAttribute("height")),
ratio: parseRatio(image.getAttribute("data-ratio")),
align,
};
}
function serializeImageHtml(attrs: Record<string, unknown>): string {
const src = String(attrs.src ?? "").trim();
const caption = String(attrs.caption ?? "").trim();
const width = parsePositiveNumber(attrs.width);
const height = parsePositiveNumber(attrs.height);
const ratio = parseRatio(attrs.ratio);
const align = clampImageAlign(attrs.align);
const encodedCaption = escapeHtmlAttribute(caption);
const widthAttribute = width > 0 ? ` width="${Math.round(width)}"` : "";
const heightAttribute = height > 0 ? ` height="${Math.round(height)}"` : "";
const ratioAttribute = width === 0 && height === 0 && ratio !== 1 ? ` data-ratio="${ratio.toFixed(2)}"` : "";
return (
`<p class="article-editor-image article-editor-image--${align}" align="${align}">` +
`<img src="${escapeHtmlAttribute(src)}"` +
` alt="${encodedCaption}"` +
(caption ? ` title="${encodedCaption}"` : "") +
widthAttribute +
heightAttribute +
ratioAttribute +
" />" +
"</p>"
);
}
function needsHtmlSerialization(node: { attrs: Record<string, unknown> }): boolean {
const width = parsePositiveNumber(node.attrs.width);
const height = parsePositiveNumber(node.attrs.height);
const align = clampImageAlign(node.attrs.align);
return width > 0 || height > 0 || align !== "center";
}
function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
return {
type: "image-block",
url: parsed.src,
title: parsed.caption,
alt: parsed.ratio === 1 ? "" : parsed.ratio.toFixed(2),
width: parsed.width,
height: parsed.height,
ratio: parsed.ratio,
align: parsed.align,
};
}
function transformRichImageNode(node: Record<string, unknown>): Record<string, unknown> {
if (node.type === "paragraph" && Array.isArray(node.children) && node.children.length === 1) {
const [firstChild] = node.children as Array<Record<string, unknown>>;
if (firstChild?.type === "image") {
return {
type: "image-block",
url: String(firstChild.url ?? ""),
title: String(firstChild.title ?? ""),
alt: String(firstChild.alt ?? ""),
width: 0,
height: 0,
ratio: parseRatio(firstChild.alt),
align: "center",
};
}
if (firstChild?.type === "html") {
const parsed = parseImageMarkup(String(firstChild.value ?? ""));
if (parsed) {
return toRichImageBlockNode(parsed);
}
}
}
if (node.type === "html") {
const parsed = parseImageMarkup(String(node.value ?? ""));
if (parsed) {
return toRichImageBlockNode(parsed);
}
}
if (Array.isArray(node.children)) {
node.children = (node.children as Array<Record<string, unknown>>).map((child) => transformRichImageNode(child));
}
return node;
}
export const richImageBlockRemarkPlugin = $remark("remark-rich-image-block", () => () => (tree) => {
transformRichImageNode(tree as unknown as Record<string, unknown>);
});
export const richImageBlockSchema = $nodeSchema("image-block", () => ({
inline: false,
group: "block",
selectable: true,
draggable: true,
isolating: true,
marks: "",
atom: true,
priority: 100,
attrs: {
src: { default: "", validate: "string" },
caption: { default: "", validate: "string" },
ratio: { default: 1, validate: "number" },
width: { default: 0, validate: "number" },
height: { default: 0, validate: "number" },
align: { default: "center", validate: "string" },
},
parseDOM: [
{
tag: `img[data-type="${IMAGE_DATA_TYPE}"]`,
getAttrs: (dom) => {
if (!(dom instanceof HTMLElement)) {
return false;
}
return {
src: dom.getAttribute("src") || "",
caption: dom.getAttribute("title") || dom.getAttribute("alt") || "",
ratio: parseRatio(dom.getAttribute("data-ratio") || dom.getAttribute("ratio")),
width: parsePositiveNumber(dom.getAttribute("width")),
height: parsePositiveNumber(dom.getAttribute("height")),
align: clampImageAlign(dom.getAttribute("align")),
};
},
},
],
toDOM: (node) => [
"img",
{
"data-type": IMAGE_DATA_TYPE,
src: node.attrs.src,
alt: node.attrs.caption,
title: node.attrs.caption,
align: node.attrs.align,
width: node.attrs.width || undefined,
height: node.attrs.height || undefined,
"data-ratio": node.attrs.ratio,
},
],
parseMarkdown: {
match: ({ type }) => type === "image-block",
runner: (state, node, type) => {
state.addNode(type, {
src: String((node as RichImageBlockNode).url ?? ""),
caption: String((node as RichImageBlockNode).title ?? ""),
ratio: parseRatio((node as RichImageBlockNode).ratio || (node as RichImageBlockNode).alt),
width: parsePositiveNumber((node as RichImageBlockNode).width),
height: parsePositiveNumber((node as RichImageBlockNode).height),
align: clampImageAlign((node as RichImageBlockNode).align),
});
},
},
toMarkdown: {
match: (node) => node.type.name === "image-block",
runner: (state, node) => {
if (needsHtmlSerialization(node)) {
state.addNode("html", undefined, serializeImageHtml(node.attrs));
return;
}
state.openNode("paragraph");
state.addNode("image", undefined, undefined, {
title: node.attrs.caption,
url: node.attrs.src,
alt: parseRatio(node.attrs.ratio) === 1 ? "" : parseRatio(node.attrs.ratio).toFixed(2),
});
state.closeNode();
},
},
}));
export const richImageBlockView = $view(richImageBlockSchema.node, (ctx) => {
const config = ctx.get(imageBlockConfig.key);
return (initialNode, view, getPos) => {
let activeNode = initialNode;
let renderToken = 0;
let captionTimer = 0;
let showCaption = Boolean(initialNode.attrs.caption);
const dom = document.createElement("div");
dom.className = "milkdown-image-block";
const wrapper = document.createElement("div");
wrapper.className = "image-wrapper";
const operation = document.createElement("div");
operation.className = "operation";
const operationItem = document.createElement("div");
operationItem.className = "operation-item";
operationItem.innerHTML = DOMPurify.sanitize((config.captionIcon || "C").trim());
operation.appendChild(operationItem);
const image = document.createElement("img");
image.setAttribute("data-type", IMAGE_DATA_TYPE);
const resizeHandle = document.createElement("div");
resizeHandle.className = "image-resize-handle";
const captionInput = document.createElement("input");
captionInput.className = "caption-input";
captionInput.placeholder = config.captionPlaceholderText;
captionInput.draggable = true;
wrapper.append(operation, image, resizeHandle);
dom.appendChild(wrapper);
const setAttr = (attr: string, value: unknown) => {
if (!view.editable) {
return;
}
const pos = getPos();
if (pos == null) {
return;
}
view.dispatch(
view.state.tr.setNodeAttribute(
pos,
attr,
attr === "src" ? DOMPurify.sanitize(String(value ?? "")) : value,
),
);
};
const syncCaptionInput = () => {
captionInput.value = String(activeNode.attrs.caption ?? "");
if (!showCaption) {
captionInput.remove();
return;
}
if (!captionInput.isConnected) {
dom.appendChild(captionInput);
}
};
const calculateBaseSize = (): { width: number; height: number } | null => {
if (!image.naturalWidth || !image.naturalHeight) {
return null;
}
let width = image.naturalWidth;
let height = image.naturalHeight;
let maxWidth = dom.getBoundingClientRect().width || width;
if (config.maxWidth) {
maxWidth = Math.min(maxWidth, config.maxWidth);
}
if (width > maxWidth) {
const scale = maxWidth / width;
width *= scale;
height *= scale;
}
if (config.maxHeight && height > config.maxHeight) {
const scale = config.maxHeight / height;
width *= scale;
height *= scale;
}
return {
width: Number(width.toFixed(2)),
height: Number(height.toFixed(2)),
};
};
const syncImageLayout = () => {
dom.dataset.align = clampImageAlign(activeNode.attrs.align);
wrapper.dataset.align = clampImageAlign(activeNode.attrs.align);
const baseSize = calculateBaseSize();
const width = parsePositiveNumber(activeNode.attrs.width);
const height = parsePositiveNumber(activeNode.attrs.height);
const ratio = parseRatio(activeNode.attrs.ratio);
image.alt = String(activeNode.attrs.caption ?? "");
image.title = String(activeNode.attrs.caption ?? "");
image.dataset.ratio = ratio.toFixed(2);
if (!baseSize) {
return;
}
let displayWidth = width;
let displayHeight = height;
if (displayWidth <= 0 && displayHeight <= 0) {
displayWidth = Number((baseSize.width * ratio).toFixed(2));
displayHeight = Number((baseSize.height * ratio).toFixed(2));
} else if (displayWidth > 0 && displayHeight <= 0) {
displayHeight = Number((displayWidth * baseSize.height / baseSize.width).toFixed(2));
} else if (displayWidth <= 0 && displayHeight > 0) {
displayWidth = Number((displayHeight * baseSize.width / baseSize.height).toFixed(2));
}
image.dataset.originWidth = baseSize.width.toFixed(2);
image.dataset.originHeight = baseSize.height.toFixed(2);
image.dataset.origin = baseSize.height.toFixed(2);
image.dataset.width = displayWidth.toFixed(2);
image.dataset.height = displayHeight.toFixed(2);
image.style.width = `${displayWidth}px`;
image.style.height = `${displayHeight}px`;
if (config.maxWidth) {
image.style.maxWidth = `${config.maxWidth}px`;
}
};
const bindImageSource = (node: typeof initialNode) => {
renderToken += 1;
const currentToken = renderToken;
const src = String(node.attrs.src ?? "");
const proxied = config.proxyDomURL?.(src) ?? src;
const applySource = (value: string) => {
if (currentToken !== renderToken) {
return;
}
image.src = value;
};
if (typeof proxied === "string") {
applySource(proxied);
return;
}
proxied.then(applySource).catch(console.error);
};
const bindAttrs = (node: typeof initialNode) => {
activeNode = node;
bindImageSource(node);
syncCaptionInput();
syncImageLayout();
};
const handleImageLoad = () => {
syncImageLayout();
};
const handleImageError = (event: Event) => {
void Promise.resolve(config.onImageLoadError?.(event)).catch(() => {});
};
const handleToggleCaption = (event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
if (!view.editable) {
return;
}
showCaption = !showCaption;
syncCaptionInput();
if (showCaption) {
requestAnimationFrame(() => {
captionInput.focus();
});
}
};
const handleCaptionInput = (event: Event) => {
const target = event.target as HTMLInputElement;
if (captionTimer) {
window.clearTimeout(captionTimer);
}
captionTimer = window.setTimeout(() => {
setAttr("caption", target.value);
}, 300);
};
const handleCaptionBlur = (event: Event) => {
const target = event.target as HTMLInputElement;
if (captionTimer) {
window.clearTimeout(captionTimer);
captionTimer = 0;
}
setAttr("caption", target.value);
};
const stopCaptionDrag = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
image.addEventListener("load", handleImageLoad);
image.addEventListener("error", handleImageError);
operationItem.addEventListener("pointerdown", handleToggleCaption);
captionInput.addEventListener("input", handleCaptionInput);
captionInput.addEventListener("blur", handleCaptionBlur);
captionInput.addEventListener("dragstart", stopCaptionDrag);
bindAttrs(initialNode);
return {
dom,
update: (updatedNode) => {
if (updatedNode.type !== initialNode.type) {
return false;
}
bindAttrs(updatedNode);
return true;
},
stopEvent: (event) => {
if (event.target instanceof HTMLInputElement) {
return true;
}
return event.target instanceof HTMLElement && Boolean(event.target.closest(".operation"));
},
selectNode: () => {
dom.classList.add("selected");
},
deselectNode: () => {
dom.classList.remove("selected");
},
destroy: () => {
if (captionTimer) {
window.clearTimeout(captionTimer);
}
image.removeEventListener("load", handleImageLoad);
image.removeEventListener("error", handleImageError);
operationItem.removeEventListener("pointerdown", handleToggleCaption);
captionInput.removeEventListener("input", handleCaptionInput);
captionInput.removeEventListener("blur", handleCaptionBlur);
captionInput.removeEventListener("dragstart", stopCaptionDrag);
dom.remove();
},
};
};
});
export const richImageBlockFeature = (editor: Editor, config?: RichImageBlockFeatureConfig) => {
editor
.config((ctx) => {
useCrepeFeatures(ctx).update((features: CrepeFeature[]) => {
if (features.includes(CrepeFeature.ImageBlock)) {
return features;
}
return [...features, CrepeFeature.ImageBlock];
});
})
.config((ctx) => {
ctx.update(inlineImageConfig.key, (value) => ({
uploadButton: config?.inlineUploadButton ?? "Upload",
imageIcon: config?.inlineImageIcon ?? value.imageIcon,
confirmButton: config?.inlineConfirmButton ?? value.confirmButton,
uploadPlaceholderText: config?.inlineUploadPlaceholderText ?? value.uploadPlaceholderText,
onUpload: config?.inlineOnUpload ?? config?.onUpload ?? value.onUpload,
proxyDomURL: config?.proxyDomURL,
}));
ctx.update(imageBlockConfig.key, (value) => ({
uploadButton: config?.blockUploadButton ?? value.uploadButton,
imageIcon: config?.blockImageIcon ?? value.imageIcon,
captionIcon: config?.blockCaptionIcon ?? value.captionIcon,
confirmButton: config?.blockConfirmButton ?? value.confirmButton,
captionPlaceholderText: config?.blockCaptionPlaceholderText ?? value.captionPlaceholderText,
uploadPlaceholderText: config?.blockUploadPlaceholderText ?? value.uploadPlaceholderText,
onUpload: config?.blockOnUpload ?? config?.onUpload ?? value.onUpload,
proxyDomURL: config?.proxyDomURL,
onImageLoadError: config?.onImageLoadError ?? value.onImageLoadError,
maxWidth: config?.maxWidth,
maxHeight: config?.maxHeight,
}));
})
.use([richImageBlockRemarkPlugin, richImageBlockSchema, richImageBlockView, imageBlockConfig].flat())
.use(imageInlineComponent);
};