Files
geo/apps/admin-web/src/components/ArticleEditorCanvas.vue
T

1883 lines
50 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
AlignCenterOutlined,
AlignLeftOutlined,
AlignRightOutlined,
BoldOutlined,
CodeOutlined,
DeleteOutlined,
InsertRowAboveOutlined,
InsertRowBelowOutlined,
InsertRowLeftOutlined,
InsertRowRightOutlined,
ItalicOutlined,
StrikethroughOutlined,
OrderedListOutlined,
MinusOutlined,
PictureOutlined,
RedoOutlined,
TableOutlined,
UndoOutlined,
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";
import {
addBlockTypeCommand,
insertHrCommand,
toggleEmphasisCommand,
toggleStrongCommand,
toggleInlineCodeCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from "@milkdown/kit/preset/commonmark";
import {
addColAfterCommand,
addColBeforeCommand,
addRowAfterCommand,
addRowBeforeCommand,
deleteSelectedCellsCommand,
insertTableCommand,
selectColCommand,
selectRowCommand,
setAlignCommand,
toggleStrikethroughCommand,
} from "@milkdown/kit/preset/gfm";
import type { Ctx } from "@milkdown/kit/ctx";
import { callCommand, replaceAll } from "@milkdown/kit/utils";
import { Crepe, CrepeFeature } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { formatError } from "@/lib/errors";
const props = defineProps<{
title: string;
modelValue: string;
disabled?: boolean;
uploadImage?: (file: File) => Promise<string>;
}>();
const emit = defineEmits<{
"update:title": [value: string];
"update:modelValue": [value: string];
}>();
const { t } = useI18n();
const crepeRef = ref<Crepe | null>(null);
const syncingExternalMarkdown = ref(false);
const tablePickerRef = ref<HTMLElement | null>(null);
const tableGridRef = ref<HTMLElement | null>(null);
const tableContextMenuRef = ref<HTMLElement | null>(null);
const imageContextMenuRef = ref<HTMLElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const TABLE_PICKER_CELL_SIZE = 22;
const TABLE_PICKER_GAP = 6;
const TABLE_PICKER_INITIAL_ROWS = 4;
const TABLE_PICKER_INITIAL_COLS = 10;
const TABLE_PICKER_MAX_ROWS = 15;
const TABLE_PICKER_MAX_COLS = 15;
const TABLE_PICKER_EXPAND_STEP = 2;
type TableContextMenuAction =
| "add-row-before"
| "add-row-after"
| "delete-row"
| "add-col-before"
| "add-col-after"
| "delete-col"
| "align-left"
| "align-center"
| "align-right"
| "delete-table";
type ImageResizeHandle = "nw" | "ne" | "sw" | "se";
type ImageContextMenuAction = "replace-image" | "toggle-caption" | "reset-size" | "delete-image";
type TableContextMenuState = {
open: boolean;
x: number;
y: number;
rowIndex: number;
colIndex: number;
tableStart: number;
tableInnerPos: number;
tableNodeSize: number;
};
type SelectedImageState = {
open: boolean;
x: number;
y: number;
width: number;
height: number;
nodePos: number;
nodeSize: number;
src: string;
};
type ImageContextMenuState = {
open: boolean;
x: number;
y: number;
nodePos: number;
nodeSize: number;
src: string;
};
type SelectedImageSnapshot = {
host: HTMLElement;
wrapper: HTMLElement;
image: HTMLImageElement;
rect: DOMRect;
nodePos: number;
nodeSize: number;
src: string;
};
function createDefaultTableContextMenuState(): TableContextMenuState {
return {
open: false,
x: 0,
y: 0,
rowIndex: 0,
colIndex: 0,
tableStart: 0,
tableInnerPos: 0,
tableNodeSize: 0,
};
}
function createDefaultSelectedImageState(): SelectedImageState {
return {
open: false,
x: 0,
y: 0,
width: 0,
height: 0,
nodePos: 0,
nodeSize: 0,
src: "",
};
}
function createDefaultImageContextMenuState(): ImageContextMenuState {
return {
open: false,
x: 0,
y: 0,
nodePos: 0,
nodeSize: 0,
src: "",
};
}
const { loading } = useEditor((root) => {
const crepe = new Crepe({
root,
defaultValue: props.modelValue || "",
features: {
[CrepeFeature.TopBar]: false,
[CrepeFeature.Toolbar]: true,
[CrepeFeature.Latex]: 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.setReadonly(!!props.disabled);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
if (syncingExternalMarkdown.value) {
return;
}
emit("update:modelValue", markdown);
});
listener.selectionUpdated((_ctx, selection) => {
scheduleSelectedImageSync(selection);
});
});
crepeRef.value = crepe;
return crepe;
});
const [instanceLoading, getEditor] = useInstance();
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
const tablePickerOpen = ref(false);
const tablePickerRows = ref(TABLE_PICKER_INITIAL_ROWS);
const tablePickerCols = ref(TABLE_PICKER_INITIAL_COLS);
const tablePreviewRow = ref(0);
const tablePreviewCol = ref(0);
const tablePointerActive = ref(false);
const imageUploading = ref(false);
const imageInputAction = ref<"insert" | "replace">("insert");
const tableContextMenu = ref<TableContextMenuState>(createDefaultTableContextMenuState());
const selectedImage = ref<SelectedImageState>(createDefaultSelectedImageState());
const imageContextMenu = ref<ImageContextMenuState>(createDefaultImageContextMenuState());
let selectedImageHostElement: HTMLElement | null = null;
let selectedImageWrapperElement: HTMLElement | null = null;
let selectedImageElement: HTMLImageElement | null = null;
let selectedImageSyncFrame = 0;
const tablePickerGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`,
}));
const tablePickerCells = computed(() => {
const cells: Array<{ key: string; row: number; col: number }> = [];
for (let row = 1; row <= tablePickerRows.value; row += 1) {
for (let col = 1; col <= tablePickerCols.value; col += 1) {
cells.push({ key: `${row}-${col}`, row, col });
}
}
return cells;
});
const tableSelectionLabel = computed(() => {
if (!tablePreviewRow.value || !tablePreviewCol.value) {
return t("article.editor.tablePicker.empty");
}
return `${tablePreviewRow.value} × ${tablePreviewCol.value}`;
});
const tableContextMenuStyle = computed(() => ({
left: `${tableContextMenu.value.x}px`,
top: `${tableContextMenu.value.y}px`,
}));
const selectedImageFrameStyle = computed(() => ({
left: `${selectedImage.value.x}px`,
top: `${selectedImage.value.y}px`,
width: `${selectedImage.value.width}px`,
height: `${selectedImage.value.height}px`,
}));
const imageContextMenuStyle = computed(() => ({
left: `${imageContextMenu.value.x}px`,
top: `${imageContextMenu.value.y}px`,
}));
const tableContextHeaderRow = computed(() => tableContextMenu.value.rowIndex === 0);
function syncMarkdownFromProps(nextValue: string): void {
const editor = getEditor();
const crepe = crepeRef.value;
if (!editor || !crepe) {
return;
}
const resolvedValue = nextValue || "";
if (crepe.getMarkdown() === resolvedValue) {
return;
}
clearSelectedImageState();
closeImageContextMenu();
syncingExternalMarkdown.value = true;
editor.action(replaceAll(resolvedValue, true));
queueMicrotask(() => {
syncingExternalMarkdown.value = false;
});
}
watch(
() => props.disabled,
(value) => {
crepeRef.value?.setReadonly(!!value);
if (value) {
closeTablePicker();
closeTableContextMenu();
clearSelectedImageState();
closeImageContextMenu();
}
},
{ immediate: true },
);
watch(
[() => props.modelValue, loading, instanceLoading],
([value, editorLoading, currentInstanceLoading]) => {
if (editorLoading || currentInstanceLoading) {
return;
}
syncMarkdownFromProps(value);
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (selectedImageSyncFrame) {
cancelAnimationFrame(selectedImageSyncFrame);
selectedImageSyncFrame = 0;
}
window.removeEventListener("pointerdown", handleWindowPointerDown);
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
window.removeEventListener("resize", handleWindowResize);
crepeRef.value = null;
});
onMounted(() => {
window.addEventListener("pointerdown", handleWindowPointerDown);
window.addEventListener("pointermove", handleWindowPointerMove);
window.addEventListener("pointerup", handleWindowPointerUp);
window.addEventListener("resize", handleWindowResize);
});
function runCommand(command: { key: unknown }, payload?: unknown): void {
if (editorDisabled.value) {
return;
}
const editor = getEditor();
if (!editor) {
return;
}
editor.action(callCommand(command.key as never, payload as never));
}
function runEditorAction(action: (ctx: Ctx) => void): void {
if (editorDisabled.value) {
return;
}
const editor = getEditor();
if (!editor) {
return;
}
editor.action(action);
}
function insertImageBlockAtCursor(src: string): void {
const resolvedSrc = src.trim();
if (!resolvedSrc) {
return;
}
runEditorAction((ctx) => {
const commands = ctx.get(commandsCtx);
commands.call(addBlockTypeCommand.key, {
nodeType: imageBlockSchema.type(ctx),
attrs: {
src: resolvedSrc,
caption: "",
ratio: 1,
},
});
requestAnimationFrame(() => {
ctx.get(editorViewCtx).focus();
});
});
}
function insertImage(): void {
if (editorDisabled.value) {
return;
}
if (props.uploadImage) {
imageInputAction.value = "insert";
imageInputRef.value?.click();
return;
}
const src = window.prompt(t("article.editor.imagePrompt"));
if (!src?.trim()) {
return;
}
insertImageBlockAtCursor(src);
}
async function handleImageFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
const action = imageInputAction.value;
imageInputAction.value = "insert";
if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) {
return;
}
imageUploading.value = true;
try {
const src = (await props.uploadImage(file)).trim();
if (!src) {
throw new Error("article_image_url_unavailable");
}
if (action === "replace" && selectedImage.value.open) {
updateSelectedImageAttribute("src", src);
return;
}
insertImageBlockAtCursor(src);
} catch (error) {
message.error(formatError(error));
} finally {
imageUploading.value = false;
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function resetTablePicker(): void {
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
tablePreviewRow.value = 0;
tablePreviewCol.value = 0;
tablePointerActive.value = false;
}
function closeTablePicker(): void {
tablePickerOpen.value = false;
resetTablePicker();
}
function closeTableContextMenu(): void {
tableContextMenu.value = createDefaultTableContextMenuState();
}
function clearSelectedImageState(): void {
selectedImage.value = createDefaultSelectedImageState();
selectedImageHostElement = null;
selectedImageWrapperElement = null;
selectedImageElement = null;
}
function closeImageContextMenu(): void {
imageContextMenu.value = createDefaultImageContextMenuState();
}
function resolveSelectedImageSnapshot(targetSelection?: Selection | null): SelectedImageSnapshot | null {
const editor = getEditor();
if (!editor) {
return null;
}
let snapshot: SelectedImageSnapshot | null = null;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const selection = targetSelection ?? view.state.selection;
if (!(selection instanceof NodeSelection) || selection.node.type.name !== "image-block") {
return;
}
const nodeDom = view.nodeDOM(selection.from);
const host =
nodeDom instanceof HTMLElement && nodeDom.classList.contains("milkdown-image-block")
? nodeDom
: (view.dom.querySelector(".milkdown-image-block.selected") as HTMLElement | null);
if (!host) {
return;
}
const wrapper = host.querySelector(".image-wrapper");
const image = wrapper?.querySelector("img");
if (!(wrapper instanceof HTMLElement) || !(image instanceof HTMLImageElement)) {
return;
}
const rect = wrapper.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
snapshot = {
host,
wrapper,
image,
rect,
nodePos: selection.from,
nodeSize: selection.node.nodeSize,
src: String(selection.node.attrs.src ?? ""),
};
});
return snapshot;
}
function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): void {
if (!snapshot) {
clearSelectedImageState();
closeImageContextMenu();
return;
}
selectedImageHostElement = snapshot.host;
selectedImageWrapperElement = snapshot.wrapper;
selectedImageElement = snapshot.image;
selectedImage.value = {
open: true,
x: snapshot.rect.left,
y: snapshot.rect.top,
width: snapshot.rect.width,
height: snapshot.rect.height,
nodePos: snapshot.nodePos,
nodeSize: snapshot.nodeSize,
src: snapshot.src,
};
if (imageContextMenu.value.open && imageContextMenu.value.nodePos !== snapshot.nodePos) {
closeImageContextMenu();
}
}
function syncSelectedImageBoundsFromDom(): void {
if (!selectedImage.value.open || !selectedImageWrapperElement) {
return;
}
if (!document.body.contains(selectedImageWrapperElement)) {
clearSelectedImageState();
closeImageContextMenu();
return;
}
const rect = selectedImageWrapperElement.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
selectedImage.value = {
...selectedImage.value,
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
}
function scheduleSelectedImageSync(selection?: Selection | null): void {
if (selectedImageSyncFrame) {
cancelAnimationFrame(selectedImageSyncFrame);
}
selectedImageSyncFrame = requestAnimationFrame(() => {
selectedImageSyncFrame = 0;
applySelectedImageSnapshot(resolveSelectedImageSnapshot(selection));
});
}
function ensureTablePickerCapacity(row: number, col: number): void {
while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP);
}
while (col > tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) {
tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP);
}
if (row === tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP);
}
if (col === tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) {
tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP);
}
}
function updateTablePreview(row: number, col: number): void {
const nextRow = clamp(row, 1, TABLE_PICKER_MAX_ROWS);
const nextCol = clamp(col, 1, TABLE_PICKER_MAX_COLS);
ensureTablePickerCapacity(nextRow, nextCol);
tablePreviewRow.value = nextRow;
tablePreviewCol.value = nextCol;
}
function resolveTableSizeFromPointer(clientX: number, clientY: number): { row: number; col: number } | null {
const grid = tableGridRef.value;
if (!grid) {
return null;
}
const rect = grid.getBoundingClientRect();
const stepX = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP;
const stepY = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP;
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
return {
row: clamp(Math.ceil((relativeY + TABLE_PICKER_GAP) / stepY), 1, TABLE_PICKER_MAX_ROWS),
col: clamp(Math.ceil((relativeX + TABLE_PICKER_GAP) / stepX), 1, TABLE_PICKER_MAX_COLS),
};
}
function insertTableFromSelection(row: number, col: number): void {
runCommand(insertTableCommand, { row, col });
closeTablePicker();
}
function toggleTablePicker(): void {
if (editorDisabled.value) {
return;
}
if (tablePickerOpen.value) {
closeTablePicker();
return;
}
closeTableContextMenu();
resetTablePicker();
tablePickerOpen.value = true;
}
function beginTableSelection(row: number, col: number): void {
if (editorDisabled.value) {
return;
}
tablePointerActive.value = true;
updateTablePreview(row, col);
}
function handleWindowPointerDown(event: PointerEvent): void {
if (!tablePickerOpen.value && !tableContextMenu.value.open && !imageContextMenu.value.open) {
return;
}
const target = event.target;
if (target instanceof Node) {
if (tablePickerOpen.value && tablePickerRef.value?.contains(target)) {
return;
}
if (tableContextMenu.value.open && tableContextMenuRef.value?.contains(target)) {
return;
}
if (imageContextMenu.value.open && imageContextMenuRef.value?.contains(target)) {
return;
}
}
if (tablePickerOpen.value) {
closeTablePicker();
}
if (tableContextMenu.value.open) {
closeTableContextMenu();
}
if (imageContextMenu.value.open) {
closeImageContextMenu();
}
}
function handleWindowPointerMove(event: PointerEvent): void {
if (!tablePickerOpen.value || !tablePointerActive.value) {
return;
}
const nextSize = resolveTableSizeFromPointer(event.clientX, event.clientY);
if (!nextSize) {
return;
}
updateTablePreview(nextSize.row, nextSize.col);
}
function handleWindowPointerUp(): void {
if (!tablePointerActive.value) {
return;
}
tablePointerActive.value = false;
if (!tablePreviewRow.value || !tablePreviewCol.value) {
closeTablePicker();
return;
}
insertTableFromSelection(tablePreviewRow.value, tablePreviewCol.value);
}
function resolveTableContextFromPointer(clientX: number, clientY: number): Omit<TableContextMenuState, "open" | "x" | "y"> | null {
const editor = getEditor();
if (!editor) {
return null;
}
let context: Omit<TableContextMenuState, "open" | "x" | "y"> | null = null;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const position = view.posAtCoords({ left: clientX, top: clientY });
if (!position) {
return;
}
const resolvedPosition = view.state.doc.resolve(position.inside > -1 ? position.inside : position.pos);
let tableDepth = -1;
let rowDepth = -1;
for (let depth = resolvedPosition.depth; depth > 0; depth -= 1) {
const nodeName = resolvedPosition.node(depth).type.name;
if (tableDepth < 0 && nodeName === "table") {
tableDepth = depth;
}
if (rowDepth < 0 && (nodeName === "table_row" || nodeName === "table_header_row")) {
rowDepth = depth;
}
}
if (tableDepth < 0 || rowDepth < 0) {
return;
}
const tableStart = resolvedPosition.before(tableDepth);
const tableNode = resolvedPosition.node(tableDepth);
context = {
rowIndex: resolvedPosition.index(tableDepth),
colIndex: resolvedPosition.index(rowDepth),
tableStart,
tableInnerPos: tableStart + 1,
tableNodeSize: tableNode.nodeSize,
};
});
return context;
}
function adjustTableContextMenuPosition(): void {
const menu = tableContextMenuRef.value;
if (!menu || !tableContextMenu.value.open) {
return;
}
const offset = 16;
const maxX = Math.max(offset, window.innerWidth - menu.offsetWidth - offset);
const maxY = Math.max(offset, window.innerHeight - menu.offsetHeight - offset);
tableContextMenu.value = {
...tableContextMenu.value,
x: clamp(tableContextMenu.value.x, offset, maxX),
y: clamp(tableContextMenu.value.y, offset, maxY),
};
}
function adjustImageContextMenuPosition(): void {
const menu = imageContextMenuRef.value;
if (!menu || !imageContextMenu.value.open) {
return;
}
const offset = 16;
const maxX = Math.max(offset, window.innerWidth - menu.offsetWidth - offset);
const maxY = Math.max(offset, window.innerHeight - menu.offsetHeight - offset);
imageContextMenu.value = {
...imageContextMenu.value,
x: clamp(imageContextMenu.value.x, offset, maxX),
y: clamp(imageContextMenu.value.y, offset, maxY),
};
}
function openTableContextMenu(event: MouseEvent): void {
const context = resolveTableContextFromPointer(event.clientX, event.clientY);
if (!context) {
closeTableContextMenu();
return;
}
closeTablePicker();
tableContextMenu.value = {
open: true,
x: event.clientX,
y: event.clientY,
...context,
};
void nextTick(() => {
adjustTableContextMenuPosition();
});
}
function resolveImageContextFromTarget(target: EventTarget | null): SelectedImageSnapshot | null {
if (!(target instanceof HTMLElement)) {
return null;
}
const host = target.closest(".milkdown-image-block");
if (!(host instanceof HTMLElement)) {
return null;
}
const editor = getEditor();
if (!editor) {
return null;
}
let snapshot: SelectedImageSnapshot | null = null;
editor.action((ctx) => {
const view = ctx.get(editorViewCtx);
const nodePos = view.posAtDOM(host, 0);
const node = view.state.doc.nodeAt(nodePos);
if (!node || node.type.name !== "image-block") {
return;
}
const wrapper = host.querySelector(".image-wrapper");
const image = wrapper?.querySelector("img");
if (!(wrapper instanceof HTMLElement) || !(image instanceof HTMLImageElement)) {
return;
}
const selection = NodeSelection.create(view.state.doc, nodePos);
if (!view.state.selection.eq(selection)) {
view.dispatch(view.state.tr.setSelection(selection).scrollIntoView());
}
snapshot = {
host,
wrapper,
image,
rect: wrapper.getBoundingClientRect(),
nodePos,
nodeSize: node.nodeSize,
src: String(node.attrs.src ?? ""),
};
});
return snapshot;
}
function openImageContextMenu(event: MouseEvent): void {
const snapshot = resolveImageContextFromTarget(event.target);
if (!snapshot) {
closeImageContextMenu();
return;
}
closeTablePicker();
closeTableContextMenu();
applySelectedImageSnapshot(snapshot);
imageContextMenu.value = {
open: true,
x: event.clientX,
y: event.clientY,
nodePos: snapshot.nodePos,
nodeSize: snapshot.nodeSize,
src: snapshot.src,
};
void nextTick(() => {
adjustImageContextMenuPosition();
});
}
function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | number): void {
if (!selectedImage.value.open) {
return;
}
const { nodePos } = selectedImage.value;
runEditorAction((ctx) => {
const view = ctx.get(editorViewCtx);
view.dispatch(view.state.tr.setNodeAttribute(nodePos, attr, value));
requestAnimationFrame(() => {
view.focus();
scheduleSelectedImageSync();
});
});
}
function deleteSelectedImage(): void {
if (!selectedImage.value.open) {
return;
}
const { nodePos, nodeSize } = selectedImage.value;
closeImageContextMenu();
clearSelectedImageState();
runEditorAction((ctx) => {
const view = ctx.get(editorViewCtx);
view.dispatch(view.state.tr.delete(nodePos, nodePos + nodeSize).scrollIntoView());
requestAnimationFrame(() => {
view.focus();
});
});
}
function toggleSelectedImageCaption(): void {
if (!selectedImageHostElement) {
return;
}
closeImageContextMenu();
const toggle = selectedImageHostElement.querySelector(".operation .operation-item");
if (toggle instanceof HTMLElement) {
toggle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }));
}
}
function replaceSelectedImage(): void {
if (!selectedImage.value.open) {
return;
}
closeImageContextMenu();
if (props.uploadImage) {
imageInputAction.value = "replace";
imageInputRef.value?.click();
return;
}
const src = window.prompt(t("article.editor.imagePrompt"), selectedImage.value.src);
if (!src?.trim()) {
return;
}
updateSelectedImageAttribute("src", src.trim());
}
function runImageContextAction(action: ImageContextMenuAction): void {
switch (action) {
case "replace-image":
replaceSelectedImage();
break;
case "toggle-caption":
toggleSelectedImageCaption();
break;
case "reset-size":
closeImageContextMenu();
updateSelectedImageAttribute("ratio", 1);
break;
case "delete-image":
deleteSelectedImage();
break;
}
}
function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle): void {
if (editorDisabled.value || !selectedImageElement || !selectedImage.value.open) {
return;
}
event.preventDefault();
event.stopPropagation();
closeImageContextMenu();
const image = selectedImageElement;
const startRect = image.getBoundingClientRect();
const startWidth = startRect.width;
const startHeight = startRect.height;
const startDiagonal = Math.hypot(startWidth, startHeight);
if (!startDiagonal) {
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;
const anchorY = handle.includes("n") ? startRect.bottom : startRect.top;
const body = document.body;
const onPointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
const currentDistance = Math.hypot(moveEvent.clientX - anchorX, moveEvent.clientY - anchorY);
const scale = clamp(currentDistance / startDiagonal, minScale, maxScale);
const nextHeight = Number((startHeight * scale).toFixed(2));
image.dataset.height = String(nextHeight);
image.style.height = `${nextHeight}px`;
syncSelectedImageBoundsFromDom();
};
const onPointerUp = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
body.style.cursor = "";
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) {
scheduleSelectedImageSync();
return;
}
updateSelectedImageAttribute("ratio", ratio);
};
body.style.cursor = handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}
function handleEditorContextMenu(event: MouseEvent): void {
if (editorDisabled.value) {
closeTableContextMenu();
closeImageContextMenu();
clearSelectedImageState();
return;
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
closeTableContextMenu();
closeImageContextMenu();
clearSelectedImageState();
return;
}
if (target.closest(".milkdown-image-block")) {
event.preventDefault();
openImageContextMenu(event);
return;
}
if (!target.closest(".milkdown-table-block") || !target.closest("td, th")) {
closeTableContextMenu();
closeImageContextMenu();
clearSelectedImageState();
return;
}
event.preventDefault();
clearSelectedImageState();
closeImageContextMenu();
openTableContextMenu(event);
}
function handleSurfaceScroll(): void {
closeTableContextMenu();
closeImageContextMenu();
syncSelectedImageBoundsFromDom();
}
function handleWindowResize(): void {
adjustTableContextMenuPosition();
adjustImageContextMenuPosition();
syncSelectedImageBoundsFromDom();
}
function runTableContextAction(action: TableContextMenuAction): void {
if (!tableContextMenu.value.open || editorDisabled.value) {
return;
}
const context = { ...tableContextMenu.value };
closeTableContextMenu();
runEditorAction((ctx) => {
const commands = ctx.get(commandsCtx);
const view = ctx.get(editorViewCtx);
const selectRow = () =>
commands.call(selectRowCommand.key, {
index: context.rowIndex,
pos: context.tableInnerPos,
});
const selectCol = () =>
commands.call(selectColCommand.key, {
index: context.colIndex,
pos: context.tableInnerPos,
});
switch (action) {
case "add-row-before":
if (context.rowIndex === 0) {
break;
}
selectRow();
commands.call(addRowBeforeCommand.key);
break;
case "add-row-after":
selectRow();
commands.call(addRowAfterCommand.key);
break;
case "delete-row":
if (context.rowIndex === 0) {
break;
}
selectRow();
commands.call(deleteSelectedCellsCommand.key);
break;
case "add-col-before":
selectCol();
commands.call(addColBeforeCommand.key);
break;
case "add-col-after":
selectCol();
commands.call(addColAfterCommand.key);
break;
case "delete-col":
selectCol();
commands.call(deleteSelectedCellsCommand.key);
break;
case "align-left":
selectCol();
commands.call(setAlignCommand.key, "left");
break;
case "align-center":
selectCol();
commands.call(setAlignCommand.key, "center");
break;
case "align-right":
selectCol();
commands.call(setAlignCommand.key, "right");
break;
case "delete-table":
view.dispatch(
view.state.tr
.delete(context.tableStart, context.tableStart + context.tableNodeSize)
.scrollIntoView(),
);
break;
}
requestAnimationFrame(() => {
view.focus();
});
});
}
</script>
<template>
<div class="article-editor-canvas" :class="{ 'article-editor-canvas--disabled': disabled }">
<div class="article-editor-canvas__toolbar">
<a-button size="small" type="text" @click="runCommand(undoCommand)">
<template #icon><IconFont type="icon-Undo" /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(redoCommand)">
<template #icon><IconFont type="icon-redo" /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 1)">H1</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 2)">H2</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 3)">H3</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(toggleStrongCommand)">
<template #icon><BoldOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(toggleEmphasisCommand)">
<template #icon><ItalicOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(toggleStrikethroughCommand)">
<template #icon><StrikethroughOutlined /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" style="font-size: 16px; font-weight: bold; line-height: 1" @click="runCommand(wrapInBlockquoteCommand)">
"
</a-button>
<a-button size="small" type="text" @click="runCommand(insertHrCommand)">
<template #icon><MinusOutlined /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(wrapInBulletListCommand)">
<template #icon><UnorderedListOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
<template #icon><OrderedListOutlined /></template>
</a-button>
<div ref="tablePickerRef" class="article-editor-canvas__table-trigger">
<a-button
size="small"
type="text"
class="article-editor-canvas__toolbar-button"
:class="{ 'article-editor-canvas__toolbar-button--active': tablePickerOpen }"
@click="toggleTablePicker"
>
<template #icon><TableOutlined /></template>
</a-button>
<div v-if="tablePickerOpen" class="article-editor-canvas__table-panel" @pointerdown.stop>
<div class="article-editor-canvas__table-panel-status">{{ tableSelectionLabel }}</div>
<div
ref="tableGridRef"
class="article-editor-canvas__table-grid"
:style="tablePickerGridStyle"
>
<button
v-for="cell in tablePickerCells"
:key="cell.key"
type="button"
class="article-editor-canvas__table-cell"
:class="{ 'article-editor-canvas__table-cell--active': cell.row <= tablePreviewRow && cell.col <= tablePreviewCol }"
@mouseenter="updateTablePreview(cell.row, cell.col)"
@focus="updateTablePreview(cell.row, cell.col)"
@pointerdown.prevent.stop="beginTableSelection(cell.row, cell.col)"
@keydown.enter.prevent="insertTableFromSelection(cell.row, cell.col)"
@keydown.space.prevent="insertTableFromSelection(cell.row, cell.col)"
></button>
</div>
<div class="article-editor-canvas__table-panel-hint">{{ t("article.editor.tablePicker.hint") }}</div>
</div>
</div>
<span class="article-editor-canvas__divider"></span>
<input
ref="imageInputRef"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="handleImageFileChange"
/>
<a-button
size="small"
type="text"
:loading="imageUploading"
:disabled="imageUploading"
@mousedown.prevent
@click="insertImage"
>
<template #icon><PictureOutlined /></template>
</a-button>
</div>
<div class="article-editor-canvas__surface" @contextmenu.capture="handleEditorContextMenu" @scroll="handleSurfaceScroll">
<div class="article-editor-canvas__title-row">
<a-input
:value="title"
size="large"
:disabled="disabled"
:bordered="false"
class="article-editor-canvas__title-input"
:placeholder="t('article.editor.titlePlaceholder')"
@update:value="emit('update:title', String($event ?? ''))"
/>
</div>
<Milkdown />
<div v-if="loading" class="article-editor-canvas__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-if="disabled" class="article-editor-canvas__mask">
{{ t("article.editor.messages.locked") }}
</div>
</div>
<Teleport to="body">
<div
v-if="selectedImage.open"
class="article-editor-canvas__image-selection"
:style="selectedImageFrameStyle"
>
<button
type="button"
class="article-editor-canvas__image-handle article-editor-canvas__image-handle--nw"
@pointerdown="beginSelectedImageResize($event, 'nw')"
></button>
<button
type="button"
class="article-editor-canvas__image-handle article-editor-canvas__image-handle--ne"
@pointerdown="beginSelectedImageResize($event, 'ne')"
></button>
<button
type="button"
class="article-editor-canvas__image-handle article-editor-canvas__image-handle--sw"
@pointerdown="beginSelectedImageResize($event, 'sw')"
></button>
<button
type="button"
class="article-editor-canvas__image-handle article-editor-canvas__image-handle--se"
@pointerdown="beginSelectedImageResize($event, 'se')"
></button>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="tableContextMenu.open"
ref="tableContextMenuRef"
class="article-editor-canvas__table-context-menu"
:style="tableContextMenuStyle"
@pointerdown.stop
@contextmenu.prevent
>
<div class="article-editor-canvas__table-context-row article-editor-canvas__table-context-row--top">
<button
type="button"
class="article-editor-canvas__table-context-action"
:disabled="tableContextHeaderRow"
@click="runTableContextAction('add-row-before')"
>
<InsertRowAboveOutlined />
<span>{{ t("article.editor.tableMenu.addRowBefore") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('add-row-after')"
>
<InsertRowBelowOutlined />
<span>{{ t("article.editor.tableMenu.addRowAfter") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action article-editor-canvas__table-context-action--danger"
:disabled="tableContextHeaderRow"
@click="runTableContextAction('delete-row')"
>
<DeleteOutlined />
<span>{{ t("article.editor.tableMenu.deleteRow") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action article-editor-canvas__table-context-action--danger"
@click="runTableContextAction('delete-table')"
>
<TableOutlined />
<span>{{ t("article.editor.tableMenu.deleteTable") }}</span>
</button>
</div>
<div class="article-editor-canvas__table-context-divider"></div>
<div class="article-editor-canvas__table-context-row article-editor-canvas__table-context-row--bottom">
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('add-col-before')"
>
<InsertRowLeftOutlined />
<span>{{ t("article.editor.tableMenu.addColBefore") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('add-col-after')"
>
<InsertRowRightOutlined />
<span>{{ t("article.editor.tableMenu.addColAfter") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action article-editor-canvas__table-context-action--danger"
@click="runTableContextAction('delete-col')"
>
<DeleteOutlined />
<span>{{ t("article.editor.tableMenu.deleteCol") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('align-left')"
>
<AlignLeftOutlined />
<span>{{ t("article.editor.tableMenu.alignLeft") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('align-center')"
>
<AlignCenterOutlined />
<span>{{ t("article.editor.tableMenu.alignCenter") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__table-context-action"
@click="runTableContextAction('align-right')"
>
<AlignRightOutlined />
<span>{{ t("article.editor.tableMenu.alignRight") }}</span>
</button>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="imageContextMenu.open"
ref="imageContextMenuRef"
class="article-editor-canvas__image-context-menu"
:style="imageContextMenuStyle"
@pointerdown.stop
@contextmenu.prevent
>
<button
type="button"
class="article-editor-canvas__image-context-action"
@click="runImageContextAction('replace-image')"
>
<PictureOutlined />
<span>{{ t("article.editor.imageMenu.replace") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__image-context-action"
@click="runImageContextAction('toggle-caption')"
>
<CodeOutlined />
<span>{{ t("article.editor.imageMenu.caption") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__image-context-action"
@click="runImageContextAction('reset-size')"
>
<RedoOutlined />
<span>{{ t("article.editor.imageMenu.resetSize") }}</span>
</button>
<button
type="button"
class="article-editor-canvas__image-context-action article-editor-canvas__image-context-action--danger"
@click="runImageContextAction('delete-image')"
>
<DeleteOutlined />
<span>{{ t("article.editor.imageMenu.delete") }}</span>
</button>
</div>
</Teleport>
</div>
</template>
<style scoped>
.article-editor-canvas {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid #edf2fa;
border-radius: 24px;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(64, 110, 255, 0.08), transparent 20%),
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
}
.article-editor-canvas__toolbar {
position: relative;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid #eef2f8;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
}
.article-editor-canvas__divider {
width: 1px;
height: 20px;
margin: 0 2px;
background: #e5ebf5;
}
.article-editor-canvas__table-trigger {
position: relative;
display: flex;
align-items: center;
}
.article-editor-canvas__toolbar :deep(.article-editor-canvas__toolbar-button--active) {
color: #245bdb;
background: rgba(47, 107, 255, 0.1);
}
.article-editor-canvas__table-panel {
position: absolute;
top: calc(100% + 12px);
left: -18px;
z-index: 24;
width: max-content;
max-width: min(calc(100vw - 48px), 520px);
padding: 18px;
border: 1px solid #e3e9f4;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 255, 0.98) 100%);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.14),
0 2px 8px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(16px);
}
.article-editor-canvas__table-panel-status {
margin-bottom: 14px;
color: #101828;
font-size: 14px;
font-weight: 700;
text-align: center;
}
.article-editor-canvas__table-grid {
display: grid;
gap: 6px;
justify-content: start;
}
.article-editor-canvas__table-cell {
width: 22px;
height: 22px;
padding: 0;
border: 1px solid #d5dbe7;
border-radius: 4px;
background: #ffffff;
cursor: crosshair;
transition:
border-color 0.14s ease,
background-color 0.14s ease,
transform 0.14s ease,
box-shadow 0.14s ease;
}
.article-editor-canvas__table-cell:hover,
.article-editor-canvas__table-cell--active {
border-color: #2f6bff;
background: rgba(47, 107, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(47, 107, 255, 0.08);
}
.article-editor-canvas__table-cell:focus-visible {
outline: 2px solid rgba(47, 107, 255, 0.36);
outline-offset: 2px;
}
.article-editor-canvas__table-panel-hint {
margin-top: 12px;
color: #667085;
font-size: 12px;
line-height: 1.4;
}
.article-editor-canvas__table-context-menu {
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 620px;
max-width: 720px;
padding: 16px;
border: 1px solid #edf2fa;
border-radius: 16px;
background: #ffffff;
box-shadow:
0 20px 48px rgba(15, 23, 42, 0.08),
0 6px 16px rgba(15, 23, 42, 0.04);
}
.article-editor-canvas__table-context-row--top {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.article-editor-canvas__table-context-row--bottom {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.article-editor-canvas__table-context-divider {
height: 1px;
background: #f0f3f8;
}
.article-editor-canvas__table-context-action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 86px;
padding: 12px 10px;
border: 1px solid transparent;
border-radius: 12px;
background: #f7f9fa;
color: #3f4a5c;
cursor: pointer;
transition: all 0.2s ease;
}
.article-editor-canvas__table-context-action span {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
text-align: center;
}
.article-editor-canvas__table-context-action :deep(svg) {
font-size: 22px;
color: #5c6b81;
transition: color 0.2s ease;
}
.article-editor-canvas__table-context-action:hover:not(:disabled) {
background: #eff4ff;
border-color: #c2d6ff;
color: #245bdb;
}
.article-editor-canvas__table-context-action:hover:not(:disabled) :deep(svg) {
color: #245bdb;
}
.article-editor-canvas__table-context-action--danger:hover:not(:disabled) {
background: #fff1f1;
border-color: #ffccc7;
color: #cf1322;
}
.article-editor-canvas__table-context-action--danger:hover:not(:disabled) :deep(svg) {
color: #cf1322;
}
.article-editor-canvas__table-context-action:disabled {
opacity: 0.46;
cursor: not-allowed;
}
.article-editor-canvas__image-selection {
position: fixed;
z-index: 1100;
box-sizing: border-box;
border: 2px solid #2f6bff;
border-radius: 18px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.9),
0 14px 28px rgba(47, 107, 255, 0.12);
pointer-events: none;
}
.article-editor-canvas__image-handle {
position: absolute;
width: 14px;
height: 14px;
border: 2px solid #ffffff;
border-radius: 999px;
background: #2f6bff;
box-shadow: 0 4px 10px rgba(47, 107, 255, 0.28);
pointer-events: auto;
}
.article-editor-canvas__image-handle--nw {
top: 0;
left: 0;
cursor: nwse-resize;
transform: translate(-50%, -50%);
}
.article-editor-canvas__image-handle--ne {
top: 0;
right: 0;
cursor: nesw-resize;
transform: translate(50%, -50%);
}
.article-editor-canvas__image-handle--sw {
bottom: 0;
left: 0;
cursor: nesw-resize;
transform: translate(-50%, 50%);
}
.article-editor-canvas__image-handle--se {
right: 0;
bottom: 0;
cursor: nwse-resize;
transform: translate(50%, 50%);
}
.article-editor-canvas__image-context-menu {
position: fixed;
z-index: 1200;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-width: 300px;
max-width: min(360px, calc(100vw - 32px));
padding: 12px;
border: 1px solid #e8eef8;
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.98) 100%);
box-shadow:
0 20px 48px rgba(15, 23, 42, 0.1),
0 6px 18px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(14px);
}
.article-editor-canvas__image-context-action {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 12px;
background: #f7f9fc;
color: #344054;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
}
.article-editor-canvas__image-context-action :deep(svg) {
font-size: 18px;
color: #52607a;
transition: color 0.18s ease;
}
.article-editor-canvas__image-context-action:hover {
border-color: #c8d8ff;
background: #edf4ff;
color: #245bdb;
transform: translateY(-1px);
}
.article-editor-canvas__image-context-action:hover :deep(svg) {
color: #245bdb;
}
.article-editor-canvas__image-context-action--danger:hover {
border-color: #ffd2cf;
background: #fff1f0;
color: #cf1322;
}
.article-editor-canvas__image-context-action--danger:hover :deep(svg) {
color: #cf1322;
}
.article-editor-canvas__surface {
position: relative;
z-index: 1;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.article-editor-canvas__title-row {
position: relative;
z-index: 0;
padding: 18px 28px 0;
}
.article-editor-canvas__title-row :deep(.ant-input) {
height: 68px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
color: #101828;
font-size: 30px !important;
font-weight: 800;
line-height: 1.15;
}
.article-editor-canvas__title-row :deep(.ant-input::placeholder) {
color: #98a2b3;
}
.article-editor-canvas__loading {
position: absolute;
inset: 0;
z-index: 2;
padding: 24px;
background: rgba(255, 255, 255, 0.92);
}
/* Removed height 100% blocks to allow natural vertical content growth and scrolling */
.article-editor-canvas__surface :deep(.ProseMirror) {
min-height: 640px;
padding: 14px 36px 80px;
background: transparent;
}
.article-editor-canvas__surface :deep(.ProseMirror:focus) {
outline: none;
}
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper) {
margin: 0 auto;
}
.article-editor-canvas__surface :deep(.milkdown-image-block.selected > .image-wrapper::before) {
background: transparent;
border: 0;
}
.article-editor-canvas__surface :deep(.milkdown-image-block img) {
max-width: min(100%, 960px);
margin: 0 auto;
}
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper .operation) {
opacity: 0 !important;
pointer-events: none;
}
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper .image-resize-handle) {
opacity: 0 !important;
pointer-events: none;
}
.article-editor-canvas__mask {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 120px;
color: #475467;
font-size: 14px;
background: rgba(255, 255, 255, 0.58);
backdrop-filter: blur(3px);
}
.article-editor-canvas--disabled .article-editor-canvas__toolbar {
opacity: 0.56;
}
.article-editor-canvas--disabled .article-editor-canvas__title-row {
opacity: 0.72;
}
</style>