2026-04-02 00:31:28 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import {
|
2026-04-05 22:41:48 +08:00
|
|
|
|
AlignCenterOutlined,
|
|
|
|
|
|
AlignLeftOutlined,
|
|
|
|
|
|
AlignRightOutlined,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
BoldOutlined,
|
2026-04-05 21:23:52 +08:00
|
|
|
|
CodeOutlined,
|
2026-04-05 22:41:48 +08:00
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
InsertRowAboveOutlined,
|
|
|
|
|
|
InsertRowBelowOutlined,
|
|
|
|
|
|
InsertRowLeftOutlined,
|
|
|
|
|
|
InsertRowRightOutlined,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
ItalicOutlined,
|
2026-04-05 21:23:52 +08:00
|
|
|
|
StrikethroughOutlined,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
OrderedListOutlined,
|
2026-04-05 21:23:52 +08:00
|
|
|
|
MinusOutlined,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
PictureOutlined,
|
|
|
|
|
|
RedoOutlined,
|
2026-04-05 21:23:52 +08:00
|
|
|
|
TableOutlined,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
UndoOutlined,
|
|
|
|
|
|
UnorderedListOutlined,
|
|
|
|
|
|
} from "@ant-design/icons-vue";
|
2026-04-05 22:10:05 +08:00
|
|
|
|
import { message } from "ant-design-vue";
|
2026-04-05 22:41:48 +08:00
|
|
|
|
import { imageBlockSchema } from "@milkdown/kit/component/image-block";
|
|
|
|
|
|
import { commandsCtx, editorViewCtx } from "@milkdown/kit/core";
|
2026-04-02 00:31:28 +08:00
|
|
|
|
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
2026-04-05 22:41:48 +08:00
|
|
|
|
import { NodeSelection, type Selection } from "@milkdown/kit/prose/state";
|
2026-04-02 00:31:28 +08:00
|
|
|
|
import {
|
2026-04-05 22:41:48 +08:00
|
|
|
|
addBlockTypeCommand,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
insertHrCommand,
|
|
|
|
|
|
toggleEmphasisCommand,
|
|
|
|
|
|
toggleStrongCommand,
|
2026-04-05 21:23:52 +08:00
|
|
|
|
toggleInlineCodeCommand,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
wrapInBlockquoteCommand,
|
|
|
|
|
|
wrapInBulletListCommand,
|
|
|
|
|
|
wrapInHeadingCommand,
|
|
|
|
|
|
wrapInOrderedListCommand,
|
|
|
|
|
|
} from "@milkdown/kit/preset/commonmark";
|
2026-04-05 22:41:48 +08:00
|
|
|
|
import {
|
|
|
|
|
|
addColAfterCommand,
|
|
|
|
|
|
addColBeforeCommand,
|
|
|
|
|
|
addRowAfterCommand,
|
|
|
|
|
|
addRowBeforeCommand,
|
|
|
|
|
|
deleteSelectedCellsCommand,
|
|
|
|
|
|
insertTableCommand,
|
|
|
|
|
|
selectColCommand,
|
|
|
|
|
|
selectRowCommand,
|
|
|
|
|
|
setAlignCommand,
|
|
|
|
|
|
toggleStrikethroughCommand,
|
|
|
|
|
|
} from "@milkdown/kit/preset/gfm";
|
|
|
|
|
|
import type { Ctx } from "@milkdown/kit/ctx";
|
2026-04-02 10:58:39 +08:00
|
|
|
|
import { callCommand, replaceAll } from "@milkdown/kit/utils";
|
2026-04-02 00:31:28 +08:00
|
|
|
|
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";
|
2026-04-05 22:41:48 +08:00
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
2026-04-02 00:31:28 +08:00
|
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
import { formatError } from "@/lib/errors";
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
modelValue: string;
|
|
|
|
|
|
disabled?: boolean;
|
2026-04-05 22:10:05 +08:00
|
|
|
|
uploadImage?: (file: File) => Promise<string>;
|
2026-04-02 00:31:28 +08:00
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
"update:title": [value: string];
|
|
|
|
|
|
"update:modelValue": [value: string];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const crepeRef = ref<Crepe | null>(null);
|
2026-04-02 10:58:39 +08:00
|
|
|
|
const syncingExternalMarkdown = ref(false);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
const tablePickerRef = ref<HTMLElement | null>(null);
|
|
|
|
|
|
const tableGridRef = ref<HTMLElement | null>(null);
|
2026-04-05 22:41:48 +08:00
|
|
|
|
const tableContextMenuRef = ref<HTMLElement | null>(null);
|
|
|
|
|
|
const imageContextMenuRef = ref<HTMLElement | null>(null);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
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;
|
2026-04-02 00:31:28 +08:00
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
const { loading } = useEditor((root) => {
|
|
|
|
|
|
const crepe = new Crepe({
|
|
|
|
|
|
root,
|
|
|
|
|
|
defaultValue: props.modelValue || "",
|
|
|
|
|
|
features: {
|
|
|
|
|
|
[CrepeFeature.TopBar]: false,
|
|
|
|
|
|
[CrepeFeature.Toolbar]: true,
|
|
|
|
|
|
[CrepeFeature.Latex]: false,
|
2026-04-05 22:41:48 +08:00
|
|
|
|
[CrepeFeature.BlockEdit]: true,
|
2026-04-02 10:58:39 +08:00
|
|
|
|
[CrepeFeature.Placeholder]: false,
|
2026-04-02 00:31:28 +08:00
|
|
|
|
},
|
2026-04-05 22:41:48 +08:00
|
|
|
|
featureConfigs: {
|
|
|
|
|
|
[CrepeFeature.ImageBlock]: {
|
|
|
|
|
|
onUpload: props.uploadImage,
|
|
|
|
|
|
proxyDomURL: (url) => url,
|
|
|
|
|
|
maxWidth: 960,
|
|
|
|
|
|
maxHeight: 720,
|
|
|
|
|
|
blockCaptionPlaceholderText: "Write image caption",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-04-02 00:31:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
crepe.setReadonly(!!props.disabled);
|
|
|
|
|
|
crepe.on((listener) => {
|
|
|
|
|
|
listener.markdownUpdated((_ctx, markdown) => {
|
2026-04-02 10:58:39 +08:00
|
|
|
|
if (syncingExternalMarkdown.value) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
emit("update:modelValue", markdown);
|
|
|
|
|
|
});
|
2026-04-05 22:41:48 +08:00
|
|
|
|
listener.selectionUpdated((_ctx, selection) => {
|
|
|
|
|
|
scheduleSelectedImageSync(selection);
|
|
|
|
|
|
});
|
2026-04-02 00:31:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
crepeRef.value = crepe;
|
|
|
|
|
|
return crepe;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const [instanceLoading, getEditor] = useInstance();
|
|
|
|
|
|
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
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);
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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;
|
2026-04-05 22:10:05 +08:00
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
});
|
2026-04-02 00:31:28 +08:00
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-04-02 10:58:39 +08:00
|
|
|
|
function syncMarkdownFromProps(nextValue: string): void {
|
|
|
|
|
|
const editor = getEditor();
|
|
|
|
|
|
const crepe = crepeRef.value;
|
|
|
|
|
|
if (!editor || !crepe) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolvedValue = nextValue || "";
|
|
|
|
|
|
if (crepe.getMarkdown() === resolvedValue) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
clearSelectedImageState();
|
|
|
|
|
|
closeImageContextMenu();
|
2026-04-02 10:58:39 +08:00
|
|
|
|
syncingExternalMarkdown.value = true;
|
|
|
|
|
|
editor.action(replaceAll(resolvedValue, true));
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
|
syncingExternalMarkdown.value = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => props.disabled,
|
|
|
|
|
|
(value) => {
|
|
|
|
|
|
crepeRef.value?.setReadonly(!!value);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
if (value) {
|
|
|
|
|
|
closeTablePicker();
|
2026-04-05 22:41:48 +08:00
|
|
|
|
closeTableContextMenu();
|
|
|
|
|
|
clearSelectedImageState();
|
|
|
|
|
|
closeImageContextMenu();
|
2026-04-05 22:10:05 +08:00
|
|
|
|
}
|
2026-04-02 00:31:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-02 10:58:39 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
[() => props.modelValue, loading, instanceLoading],
|
|
|
|
|
|
([value, editorLoading, currentInstanceLoading]) => {
|
|
|
|
|
|
if (editorLoading || currentInstanceLoading) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
syncMarkdownFromProps(value);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
2026-04-05 22:41:48 +08:00
|
|
|
|
if (selectedImageSyncFrame) {
|
|
|
|
|
|
cancelAnimationFrame(selectedImageSyncFrame);
|
|
|
|
|
|
selectedImageSyncFrame = 0;
|
|
|
|
|
|
}
|
2026-04-05 22:10:05 +08:00
|
|
|
|
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
|
|
|
|
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
|
|
|
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
2026-04-05 22:41:48 +08:00
|
|
|
|
window.removeEventListener("resize", handleWindowResize);
|
2026-04-02 00:31:28 +08:00
|
|
|
|
crepeRef.value = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
window.addEventListener("pointerdown", handleWindowPointerDown);
|
|
|
|
|
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
|
|
|
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
2026-04-05 22:41:48 +08:00
|
|
|
|
window.addEventListener("resize", handleWindowResize);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
function insertImage(): void {
|
|
|
|
|
|
if (editorDisabled.value) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
if (props.uploadImage) {
|
2026-04-05 22:41:48 +08:00
|
|
|
|
imageInputAction.value = "insert";
|
2026-04-05 22:10:05 +08:00
|
|
|
|
imageInputRef.value?.click();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
const src = window.prompt(t("article.editor.imagePrompt"));
|
|
|
|
|
|
if (!src?.trim()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
insertImageBlockAtCursor(src);
|
2026-04-02 00:31:28 +08:00
|
|
|
|
}
|
2026-04-05 21:23:52 +08:00
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
async function handleImageFileChange(event: Event): Promise<void> {
|
|
|
|
|
|
const input = event.target as HTMLInputElement;
|
|
|
|
|
|
const file = input.files?.[0];
|
|
|
|
|
|
input.value = "";
|
2026-04-05 22:41:48 +08:00
|
|
|
|
const action = imageInputAction.value;
|
|
|
|
|
|
imageInputAction.value = "insert";
|
2026-04-05 22:10:05 +08:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
if (action === "replace" && selectedImage.value.open) {
|
|
|
|
|
|
updateSelectedImageAttribute("src", src);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
insertImageBlockAtCursor(src);
|
2026-04-05 22:10:05 +08:00
|
|
|
|
} 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
closeTableContextMenu();
|
2026-04-05 22:10:05 +08:00
|
|
|
|
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 {
|
2026-04-05 22:41:48 +08:00
|
|
|
|
if (!tablePickerOpen.value && !tableContextMenu.value.open && !imageContextMenu.value.open) {
|
2026-04-05 22:10:05 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const target = event.target;
|
2026-04-05 22:41:48 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-05 22:10:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
if (tablePickerOpen.value) {
|
|
|
|
|
|
closeTablePicker();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tableContextMenu.value.open) {
|
|
|
|
|
|
closeTableContextMenu();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (imageContextMenu.value.open) {
|
|
|
|
|
|
closeImageContextMenu();
|
|
|
|
|
|
}
|
2026-04-05 22:10:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-05 21:23:52 +08:00
|
|
|
|
}
|
2026-04-05 22:41:48 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-02 00:31:28 +08:00
|
|
|
|
</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)">
|
2026-04-05 21:23:52 +08:00
|
|
|
|
<template #icon><IconFont type="icon-Undo" /></template>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button size="small" type="text" @click="runCommand(redoCommand)">
|
2026-04-05 21:23:52 +08:00
|
|
|
|
<template #icon><IconFont type="icon-redo" /></template>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
</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>
|
2026-04-05 21:23:52 +08:00
|
|
|
|
<a-button size="small" type="text" @click="runCommand(toggleStrikethroughCommand)">
|
|
|
|
|
|
<template #icon><StrikethroughOutlined /></template>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
</a-button>
|
2026-04-05 21:23:52 +08:00
|
|
|
|
<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>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
<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>
|
2026-04-05 22:10:05 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-05 21:23:52 +08:00
|
|
|
|
<span class="article-editor-canvas__divider"></span>
|
2026-04-05 22:10:05 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
<template #icon><PictureOutlined /></template>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
<div class="article-editor-canvas__surface" @contextmenu.capture="handleEditorContextMenu" @scroll="handleSurfaceScroll">
|
2026-04-02 00:31:28 +08:00
|
|
|
|
<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>
|
2026-04-05 22:41:48 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-04-02 00:31:28 +08:00
|
|
|
|
</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 {
|
2026-04-05 22:10:05 +08:00
|
|
|
|
position: relative;
|
|
|
|
|
|
z-index: 3;
|
2026-04-02 00:31:28 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:10:05 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
.article-editor-canvas__surface {
|
|
|
|
|
|
position: relative;
|
2026-04-05 22:10:05 +08:00
|
|
|
|
z-index: 1;
|
2026-04-02 00:31:28 +08:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.article-editor-canvas__title-row {
|
|
|
|
|
|
position: relative;
|
2026-04-05 22:10:05 +08:00
|
|
|
|
z-index: 0;
|
2026-04-02 00:31:28 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 22:41:48 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 00:31:28 +08:00
|
|
|
|
.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>
|