diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index a02e8f5..b82fea9 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -21,6 +21,7 @@ "@vueuse/core": "^14.2.1", "ant-design-vue": "^4.2.6", "dayjs": "^1.11.20", + "dompurify": "^3.3.3", "markdown-it": "^14.1.1", "pinia": "^3.0.4", "vue": "^3.5.31", diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 1708a6a..dd6ee87 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -21,7 +21,6 @@ import { UnorderedListOutlined, } from "@ant-design/icons-vue"; import { message } from "ant-design-vue"; -import { imageBlockSchema } from "@milkdown/kit/component/image-block"; import { commandsCtx, editorViewCtx } from "@milkdown/kit/core"; import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history"; import { NodeSelection, type Selection } from "@milkdown/kit/prose/state"; @@ -58,6 +57,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue" import { useI18n } from "vue-i18n"; import { formatError } from "@/lib/errors"; +import { + richImageBlockFeature, + richImageBlockSchema, + type RichImageAlign, +} from "@/lib/milkdown/richImageBlock"; const props = defineProps<{ title: string; @@ -103,7 +107,14 @@ type TableContextMenuAction = type ImageResizeHandle = "nw" | "ne" | "sw" | "se"; -type ImageContextMenuAction = "replace-image" | "toggle-caption" | "reset-size" | "delete-image"; +type ImageContextMenuAction = + | "replace-image" + | "toggle-caption" + | "reset-size" + | "align-left" + | "align-center" + | "align-right" + | "delete-image"; type TableContextMenuState = { open: boolean; @@ -125,6 +136,7 @@ type SelectedImageState = { nodePos: number; nodeSize: number; src: string; + align: RichImageAlign; }; type ImageContextMenuState = { @@ -134,6 +146,7 @@ type ImageContextMenuState = { nodePos: number; nodeSize: number; src: string; + align: RichImageAlign; }; type SelectedImageSnapshot = { @@ -144,6 +157,7 @@ type SelectedImageSnapshot = { nodePos: number; nodeSize: number; src: string; + align: RichImageAlign; }; function createDefaultTableContextMenuState(): TableContextMenuState { @@ -169,6 +183,7 @@ function createDefaultSelectedImageState(): SelectedImageState { nodePos: 0, nodeSize: 0, src: "", + align: "center", }; } @@ -180,6 +195,7 @@ function createDefaultImageContextMenuState(): ImageContextMenuState { nodePos: 0, nodeSize: 0, src: "", + align: "center", }; } @@ -191,18 +207,17 @@ const { loading } = useEditor((root) => { [CrepeFeature.TopBar]: false, [CrepeFeature.Toolbar]: true, [CrepeFeature.Latex]: false, + [CrepeFeature.ImageBlock]: false, [CrepeFeature.BlockEdit]: true, [CrepeFeature.Placeholder]: false, }, - featureConfigs: { - [CrepeFeature.ImageBlock]: { - onUpload: props.uploadImage, - proxyDomURL: (url) => url, - maxWidth: 960, - maxHeight: 720, - blockCaptionPlaceholderText: "Write image caption", - }, - }, + }); + crepe.addFeature(richImageBlockFeature, { + onUpload: props.uploadImage, + proxyDomURL: (url) => url, + maxWidth: 960, + maxHeight: 720, + blockCaptionPlaceholderText: "Write image caption", }); crepe.setReadonly(!!props.disabled); @@ -390,11 +405,14 @@ function insertImageBlockAtCursor(src: string): void { runEditorAction((ctx) => { const commands = ctx.get(commandsCtx); commands.call(addBlockTypeCommand.key, { - nodeType: imageBlockSchema.type(ctx), + nodeType: richImageBlockSchema.type(ctx), attrs: { src: resolvedSrc, caption: "", ratio: 1, + width: 0, + height: 0, + align: "center", }, }); @@ -442,7 +460,12 @@ async function handleImageFileChange(event: Event): Promise { } if (action === "replace" && selectedImage.value.open) { - updateSelectedImageAttribute("src", src); + updateSelectedImageAttributes({ + src, + ratio: 1, + width: 0, + height: 0, + }); return; } @@ -458,6 +481,14 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +function resolveImageAlign(value: unknown): RichImageAlign { + const normalized = String(value ?? "").trim().toLowerCase(); + if (normalized === "left" || normalized === "right") { + return normalized; + } + return "center"; +} + function resetTablePicker(): void { tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS; tablePickerCols.value = TABLE_PICKER_INITIAL_COLS; @@ -590,6 +621,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec nodePos: selection.from, nodeSize: selection.node.nodeSize, src: String(selection.node.attrs.src ?? ""), + align: resolveImageAlign(selection.node.attrs.align), }; }); @@ -622,6 +654,7 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi nodePos: snapshot.nodePos, nodeSize: snapshot.nodeSize, src: snapshot.src, + align: snapshot.align, }; attachSelectedImageDomSync(); @@ -966,6 +999,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag nodePos, nodeSize: node.nodeSize, src: String(node.attrs.src ?? ""), + align: resolveImageAlign(node.attrs.align), }; }); @@ -989,6 +1023,7 @@ function openImageContextMenu(event: MouseEvent): void { nodePos: snapshot.nodePos, nodeSize: snapshot.nodeSize, src: snapshot.src, + align: snapshot.align, }; void nextTick(() => { @@ -1028,7 +1063,9 @@ function handleEditorDrop(): void { finishEditorImageDrag(); } -function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | number): void { +function updateSelectedImageAttributes( + attrs: Partial>, +): void { if (!selectedImage.value.open) { return; } @@ -1036,7 +1073,11 @@ function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | num const { nodePos } = selectedImage.value; runEditorAction((ctx) => { const view = ctx.get(editorViewCtx); - view.dispatch(view.state.tr.setNodeAttribute(nodePos, attr, value)); + let transaction = view.state.tr; + for (const [attr, value] of Object.entries(attrs)) { + transaction = transaction.setNodeAttribute(nodePos, attr, value); + } + view.dispatch(transaction); requestAnimationFrame(() => { view.focus(); scheduleSelectedImageSync(); @@ -1044,6 +1085,13 @@ function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | num }); } +function updateSelectedImageAttribute( + attr: "src" | "ratio" | "width" | "height" | "align", + value: string | number, +): void { + updateSelectedImageAttributes({ [attr]: value }); +} + function deleteSelectedImage(): void { if (!selectedImage.value.open) { return; @@ -1091,7 +1139,12 @@ function replaceSelectedImage(): void { return; } - updateSelectedImageAttribute("src", src.trim()); + updateSelectedImageAttributes({ + src: src.trim(), + ratio: 1, + width: 0, + height: 0, + }); } function runImageContextAction(action: ImageContextMenuAction): void { @@ -1104,7 +1157,23 @@ function runImageContextAction(action: ImageContextMenuAction): void { break; case "reset-size": closeImageContextMenu(); - updateSelectedImageAttribute("ratio", 1); + updateSelectedImageAttributes({ + ratio: 1, + width: 0, + height: 0, + }); + break; + case "align-left": + closeImageContextMenu(); + updateSelectedImageAttribute("align", "left"); + break; + case "align-center": + closeImageContextMenu(); + updateSelectedImageAttribute("align", "center"); + break; + case "align-right": + closeImageContextMenu(); + updateSelectedImageAttribute("align", "right"); break; case "delete-image": deleteSelectedImage(); @@ -1130,7 +1199,6 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle return; } - const originHeight = Number(image.dataset.origin || startHeight); const minScale = Math.max(100 / Math.max(startWidth, 1), 100 / Math.max(startHeight, 1)); const maxScale = Math.min(960 / Math.max(startWidth, 1), 720 / Math.max(startHeight, 1)); const anchorX = handle.includes("w") ? startRect.right : startRect.left; @@ -1142,9 +1210,12 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle const currentDistance = Math.hypot(moveEvent.clientX - anchorX, moveEvent.clientY - anchorY); const scale = clamp(currentDistance / startDiagonal, minScale, maxScale); + const nextWidth = Number((startWidth * scale).toFixed(2)); const nextHeight = Number((startHeight * scale).toFixed(2)); + image.dataset.width = String(nextWidth); image.dataset.height = String(nextHeight); + image.style.width = `${nextWidth}px`; image.style.height = `${nextHeight}px`; syncSelectedImageBoundsFromDom(); }; @@ -1154,14 +1225,18 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle window.removeEventListener("pointerup", onPointerUp); body.style.cursor = ""; + const currentWidth = Number(image.dataset.width || image.getBoundingClientRect().width); const currentHeight = Number(image.dataset.height || image.getBoundingClientRect().height); - const ratio = Number.parseFloat((currentHeight / Math.max(originHeight, 1)).toFixed(2)); - if (Number.isNaN(ratio) || ratio <= 0) { + if (Number.isNaN(currentWidth) || currentWidth <= 0 || Number.isNaN(currentHeight) || currentHeight <= 0) { scheduleSelectedImageSync(); return; } - updateSelectedImageAttribute("ratio", ratio); + updateSelectedImageAttributes({ + ratio: 1, + width: currentWidth, + height: currentHeight, + }); }; body.style.cursor = handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize"; @@ -1576,6 +1651,33 @@ function runTableContextAction(action: TableContextMenuAction): void { {{ t("article.editor.imageMenu.caption") }} + + +