Files
geo/apps/admin-web/src/components/ArticleEditorCanvas.vue
T
root 0e2ad4ed16 feat: add table and image context menus with localization support
- Implemented context menus for table operations including adding, deleting rows/columns, and alignment options.
- Added context menu for image operations such as replacing, toggling caption, resetting size, and deleting images.
- Updated English and Chinese localization files to include new menu options for table and image functionalities.
2026-04-05 22:41:48 +08:00

1883 lines
50 KiB
Vue
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.
<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>