021ef7fc79
Import individual Crepe common stylesheets instead of the aggregate, which transitively pulled in latex.css and emitted ~59 KaTeX font files even though CrepeFeature.Latex is disabled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2241 lines
61 KiB
Vue
2241 lines
61 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
AlignCenterOutlined,
|
|
AlignLeftOutlined,
|
|
AlignRightOutlined,
|
|
BoldOutlined,
|
|
CodeOutlined,
|
|
DeleteOutlined,
|
|
InsertRowAboveOutlined,
|
|
InsertRowBelowOutlined,
|
|
InsertRowLeftOutlined,
|
|
InsertRowRightOutlined,
|
|
ItalicOutlined,
|
|
StrikethroughOutlined,
|
|
OrderedListOutlined,
|
|
MinusOutlined,
|
|
PictureOutlined,
|
|
RedoOutlined,
|
|
TableOutlined,
|
|
UnorderedListOutlined,
|
|
} from "@ant-design/icons-vue";
|
|
import { message } from "ant-design-vue";
|
|
import { commandsCtx, editorViewCtx, parserCtx } from "@milkdown/kit/core";
|
|
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
|
import { Slice } from "@milkdown/kit/prose/model";
|
|
import { NodeSelection, TextSelection, type Selection } from "@milkdown/kit/prose/state";
|
|
import type { EditorView } from "@milkdown/kit/prose/view";
|
|
import {
|
|
addBlockTypeCommand,
|
|
insertHrCommand,
|
|
toggleEmphasisCommand,
|
|
toggleStrongCommand,
|
|
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";
|
|
// NOTE: import each common stylesheet explicitly instead of the aggregate
|
|
// `theme/common/style.css`. The aggregate transitively `@import`s `latex.css`,
|
|
// which pulls in `katex/dist/katex.min.css` and emits ~59 KaTeX font files
|
|
// into dist/assets/fonts even though we disable CrepeFeature.Latex. We don't
|
|
// render math, so skip latex.css to drop the fonts from the bundle.
|
|
import "@milkdown/crepe/theme/common/prosemirror.css";
|
|
import "@milkdown/crepe/theme/common/reset.css";
|
|
import "@milkdown/crepe/theme/common/block-edit.css";
|
|
import "@milkdown/crepe/theme/common/code-mirror.css";
|
|
import "@milkdown/crepe/theme/common/cursor.css";
|
|
import "@milkdown/crepe/theme/common/image-block.css";
|
|
import "@milkdown/crepe/theme/common/link-tooltip.css";
|
|
import "@milkdown/crepe/theme/common/list-item.css";
|
|
import "@milkdown/crepe/theme/common/placeholder.css";
|
|
import "@milkdown/crepe/theme/common/toolbar.css";
|
|
import "@milkdown/crepe/theme/common/table.css";
|
|
import "@milkdown/crepe/theme/common/top-bar.css";
|
|
import "@milkdown/crepe/theme/frame.css";
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
shallowRef,
|
|
watch,
|
|
type ComponentPublicInstance,
|
|
} from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
|
|
import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue";
|
|
import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue";
|
|
import EditorGridSizePicker from "@/components/editor-common/EditorGridSizePicker.vue";
|
|
import EditorSelectionFrame from "@/components/editor-common/EditorSelectionFrame.vue";
|
|
import CoverPickerModal from "@/components/CoverPickerModal.vue";
|
|
import MarkdownPreview from "@/components/MarkdownPreview.vue";
|
|
import {
|
|
imagesApi,
|
|
streamArticleSelectionOptimize,
|
|
type ArticleSelectionOptimizeStreamEvent,
|
|
} from "@/lib/api";
|
|
import { formatError } from "@/lib/errors";
|
|
import {
|
|
richImageBlockFeature,
|
|
richImageBlockSchema,
|
|
type RichImageAlign,
|
|
} from "@/lib/milkdown/richImageBlock";
|
|
|
|
const props = defineProps<{
|
|
articleId: number;
|
|
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 = shallowRef<Crepe | null>(null);
|
|
const syncingExternalMarkdown = ref(false);
|
|
const editorRootRef = ref<HTMLElement | null>(null);
|
|
const editorRef = shallowRef<MilkdownEditorInstance | null>(null);
|
|
const loading = ref(true);
|
|
const surfaceRef = ref<HTMLElement | null>(null);
|
|
const tablePickerRef = ref<HTMLElement | null>(null);
|
|
|
|
const AI_OPTIMIZE_PANEL_GAP = 18;
|
|
const AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME = "article-editor-ai-optimize-selection";
|
|
const AI_OPTIMIZE_TOOLBAR_ICON = `
|
|
<svg
|
|
class="svgfont"
|
|
aria-hidden="true"
|
|
viewBox="0 0 1024 1024"
|
|
width="1em"
|
|
height="1em"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M716.571429 332l167.428571-167.428571-61.142857-61.142857-167.428571 167.428571zm255.428571-167.428571q0 15.428571-10.285714 25.714286l-734.857143 734.857143q-10.285714 10.285714-25.714286 10.285714t-25.714286-10.285714l-113.142857-113.142857q-10.285714-10.285714-10.285714-25.714286t10.285714-25.714286l734.857143-734.857143q10.285714-10.285714 25.714286-10.285714t25.714286 10.285714l113.142857 113.142857q10.285714 10.285714 10.285714 25.714286zm-772-108.571429l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56zm200 92.571429l112 34.285714-112 34.285714-34.285714 112-34.285714-112-112-34.285714 112-34.285714 34.285714-112zm531.428571 273.142857l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56zm-365.714286-365.714286l56 17.142857-56 17.142857-17.142857 56-17.142857-56-56-17.142857 56-17.142857 17.142857-56z"></path>
|
|
</svg>
|
|
`;
|
|
|
|
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"
|
|
| "align-left"
|
|
| "align-center"
|
|
| "align-right"
|
|
| "delete-image";
|
|
|
|
type ImagePickerAction = "insert" | "replace";
|
|
|
|
type AiOptimizeStatus = "idle" | "generating" | "completed" | "error";
|
|
type EditorAiAssistPlacement = "bottom" | "top";
|
|
|
|
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;
|
|
assetId: number | null;
|
|
align: RichImageAlign;
|
|
};
|
|
|
|
type AiOptimizePanelState = {
|
|
open: boolean;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
placement: EditorAiAssistPlacement;
|
|
from: number;
|
|
to: number;
|
|
selectedText: string;
|
|
};
|
|
|
|
type TextSelectionSnapshot = {
|
|
from: number;
|
|
to: number;
|
|
text: string;
|
|
rect: DOMRect;
|
|
};
|
|
|
|
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;
|
|
assetId: number | null;
|
|
align: RichImageAlign;
|
|
};
|
|
|
|
type MilkdownEditorInstance = Awaited<ReturnType<Crepe["create"]>>;
|
|
|
|
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: "",
|
|
assetId: null,
|
|
align: "center",
|
|
};
|
|
}
|
|
|
|
function createDefaultImageContextMenuState(): ImageContextMenuState {
|
|
return {
|
|
open: false,
|
|
x: 0,
|
|
y: 0,
|
|
nodePos: 0,
|
|
nodeSize: 0,
|
|
src: "",
|
|
};
|
|
}
|
|
|
|
function createDefaultAiOptimizePanelState(): AiOptimizePanelState {
|
|
return {
|
|
open: false,
|
|
x: 0,
|
|
y: 0,
|
|
width: 0,
|
|
placement: "bottom",
|
|
from: 0,
|
|
to: 0,
|
|
selectedText: "",
|
|
};
|
|
}
|
|
|
|
function createCrepe(root: HTMLElement): Crepe {
|
|
const crepe = new Crepe({
|
|
root,
|
|
defaultValue: props.modelValue || "",
|
|
features: {
|
|
[CrepeFeature.TopBar]: false,
|
|
[CrepeFeature.Toolbar]: true,
|
|
[CrepeFeature.Latex]: false,
|
|
[CrepeFeature.ImageBlock]: false,
|
|
[CrepeFeature.BlockEdit]: true,
|
|
[CrepeFeature.Placeholder]: false,
|
|
},
|
|
featureConfigs: {
|
|
[CrepeFeature.Toolbar]: {
|
|
buildToolbar: (builder) => {
|
|
builder.addGroup("ai-optimize", "AI Optimize").addItem("ai-optimize", {
|
|
icon: AI_OPTIMIZE_TOOLBAR_ICON,
|
|
active: () => false,
|
|
onRun: (ctx) => {
|
|
openAiOptimizePanel(ctx);
|
|
},
|
|
});
|
|
},
|
|
},
|
|
},
|
|
});
|
|
crepe.addFeature(richImageBlockFeature, {
|
|
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;
|
|
}
|
|
|
|
function getEditor(): MilkdownEditorInstance | null {
|
|
return editorRef.value;
|
|
}
|
|
|
|
const editorDisabled = computed(() => props.disabled || loading.value);
|
|
const tablePickerOpen = ref(false);
|
|
const imagePickerOpen = ref(false);
|
|
const imagePickerAction = ref<ImagePickerAction>("insert");
|
|
const imagePickerCurrentUrl = ref("");
|
|
const imagePickerCurrentAssetId = ref<number | null>(null);
|
|
const tableContextMenu = ref<TableContextMenuState>(createDefaultTableContextMenuState());
|
|
const selectedImage = ref<SelectedImageState>(createDefaultSelectedImageState());
|
|
const imageContextMenu = ref<ImageContextMenuState>(createDefaultImageContextMenuState());
|
|
const aiOptimizePanel = ref<AiOptimizePanelState>(createDefaultAiOptimizePanelState());
|
|
const aiOptimizeInstruction = ref("");
|
|
const aiOptimizePreview = ref("");
|
|
const aiOptimizeError = ref("");
|
|
const aiOptimizeStatus = ref<AiOptimizeStatus>("idle");
|
|
|
|
let selectedImageHostElement: HTMLElement | null = null;
|
|
let selectedImageWrapperElement: HTMLElement | null = null;
|
|
let selectedImageElement: HTMLImageElement | null = null;
|
|
let selectedImageSyncFrame = 0;
|
|
let imageDragActive = false;
|
|
let selectedImageResizeObserver: ResizeObserver | null = null;
|
|
let selectedImageLoadHandler: (() => void) | null = null;
|
|
let aiOptimizeAbortController: AbortController | null = null;
|
|
let aiOptimizeIgnoreNextWindowPointerDown = false;
|
|
|
|
const EDITOR_ACTION_MENU_DROPDOWN_SELECTOR = ".editor-action-menu__dropdown-overlay";
|
|
|
|
const tableContextHeaderRow = computed(() => tableContextMenu.value.rowIndex === 0);
|
|
const aiOptimizeStreaming = computed(() => aiOptimizeStatus.value === "generating");
|
|
const aiOptimizeHasPreview = computed(() => aiOptimizePreview.value.trim().length > 0);
|
|
const aiOptimizeUiText = computed(() => ({
|
|
title: t("article.editor.aiOptimize.title"),
|
|
promptPlaceholder: t("article.editor.aiOptimize.promptPlaceholder"),
|
|
generating: t("article.editor.aiOptimize.generating"),
|
|
failed: t("article.editor.aiOptimize.messages.failed"),
|
|
disclaimer: t("article.editor.aiOptimize.disclaimer"),
|
|
regenerate: t("article.editor.aiOptimize.regenerate"),
|
|
close: t("article.editor.aiOptimize.close"),
|
|
replace: t("article.editor.aiOptimize.replace"),
|
|
}));
|
|
|
|
const tableContextMenuGroups = computed(() => [
|
|
{
|
|
key: "operations",
|
|
columns: 4,
|
|
items: [
|
|
{
|
|
key: "rows",
|
|
label: "行操作",
|
|
icon: InsertRowAboveOutlined,
|
|
children: [
|
|
{
|
|
key: "add-row-before",
|
|
label: t("article.editor.tableMenu.addRowBefore"),
|
|
icon: InsertRowAboveOutlined,
|
|
disabled: tableContextHeaderRow.value,
|
|
},
|
|
{
|
|
key: "add-row-after",
|
|
label: t("article.editor.tableMenu.addRowAfter"),
|
|
icon: InsertRowBelowOutlined,
|
|
},
|
|
{
|
|
key: "delete-row",
|
|
label: t("article.editor.tableMenu.deleteRow"),
|
|
icon: DeleteOutlined,
|
|
danger: true,
|
|
disabled: tableContextHeaderRow.value,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "columns",
|
|
label: "列操作",
|
|
icon: InsertRowLeftOutlined,
|
|
children: [
|
|
{
|
|
key: "add-col-before",
|
|
label: t("article.editor.tableMenu.addColBefore"),
|
|
icon: InsertRowLeftOutlined,
|
|
},
|
|
{
|
|
key: "add-col-after",
|
|
label: t("article.editor.tableMenu.addColAfter"),
|
|
icon: InsertRowRightOutlined,
|
|
},
|
|
{
|
|
key: "delete-col",
|
|
label: t("article.editor.tableMenu.deleteCol"),
|
|
icon: DeleteOutlined,
|
|
danger: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "align",
|
|
label: "对齐方式",
|
|
icon: AlignCenterOutlined,
|
|
children: [
|
|
{
|
|
key: "align-left",
|
|
label: t("article.editor.tableMenu.alignLeft"),
|
|
icon: AlignLeftOutlined,
|
|
},
|
|
{
|
|
key: "align-center",
|
|
label: t("article.editor.tableMenu.alignCenter"),
|
|
icon: AlignCenterOutlined,
|
|
},
|
|
{
|
|
key: "align-right",
|
|
label: t("article.editor.tableMenu.alignRight"),
|
|
icon: AlignRightOutlined,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "delete-table",
|
|
label: t("article.editor.tableMenu.deleteTable"),
|
|
icon: DeleteOutlined,
|
|
danger: true,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
const imageContextMenuGroups = computed(() => [
|
|
{
|
|
key: "operations",
|
|
columns: 4,
|
|
items: [
|
|
{
|
|
key: "image",
|
|
label: "图片操作",
|
|
icon: PictureOutlined,
|
|
children: [
|
|
{
|
|
key: "replace-image",
|
|
label: t("article.editor.imageMenu.replace"),
|
|
icon: PictureOutlined,
|
|
},
|
|
{
|
|
key: "toggle-caption",
|
|
label: t("article.editor.imageMenu.caption"),
|
|
icon: CodeOutlined,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "align",
|
|
label: "对齐方式",
|
|
icon: AlignCenterOutlined,
|
|
children: [
|
|
{
|
|
key: "align-left",
|
|
label: t("article.editor.imageMenu.alignLeft"),
|
|
icon: AlignLeftOutlined,
|
|
active: selectedImage.value.align === "left",
|
|
},
|
|
{
|
|
key: "align-center",
|
|
label: t("article.editor.imageMenu.alignCenter"),
|
|
icon: AlignCenterOutlined,
|
|
active: selectedImage.value.align === "center",
|
|
},
|
|
{
|
|
key: "align-right",
|
|
label: t("article.editor.imageMenu.alignRight"),
|
|
icon: AlignRightOutlined,
|
|
active: selectedImage.value.align === "right",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "reset-size",
|
|
label: t("article.editor.imageMenu.resetSize"),
|
|
icon: RedoOutlined,
|
|
},
|
|
{
|
|
key: "delete-image",
|
|
label: t("article.editor.imageMenu.delete"),
|
|
icon: DeleteOutlined,
|
|
danger: true,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
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();
|
|
closeAiOptimizePanel();
|
|
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();
|
|
imagePickerOpen.value = false;
|
|
closeAiOptimizePanel();
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
[() => props.modelValue, loading],
|
|
([value, editorLoading]) => {
|
|
if (editorLoading) {
|
|
return;
|
|
}
|
|
|
|
syncMarkdownFromProps(value);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
async function mountEditor(): Promise<void> {
|
|
const root = editorRootRef.value;
|
|
if (!root || crepeRef.value) {
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
|
|
const crepe = createCrepe(root);
|
|
|
|
try {
|
|
editorRef.value = await crepe.create();
|
|
} catch (error) {
|
|
crepeRef.value = null;
|
|
console.error(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
stopAiOptimizeGeneration();
|
|
if (selectedImageSyncFrame) {
|
|
cancelAnimationFrame(selectedImageSyncFrame);
|
|
selectedImageSyncFrame = 0;
|
|
}
|
|
detachSelectedImageDomSync();
|
|
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
|
window.removeEventListener("resize", handleWindowResize);
|
|
window.removeEventListener("keydown", handleWindowKeydown);
|
|
editorRef.value = null;
|
|
void crepeRef.value?.destroy().catch(console.error);
|
|
crepeRef.value = null;
|
|
});
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("pointerdown", handleWindowPointerDown);
|
|
window.addEventListener("resize", handleWindowResize);
|
|
window.addEventListener("keydown", handleWindowKeydown);
|
|
void mountEditor();
|
|
});
|
|
|
|
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 resolveImageAssetId(value: unknown): number | null {
|
|
const resolved = Number.parseInt(String(value ?? "").trim(), 10);
|
|
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
return null;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function insertImageBlockAtCursor(payload: { src: string; assetId?: number | null }): void {
|
|
const resolvedSrc = payload.src.trim();
|
|
if (!resolvedSrc) {
|
|
return;
|
|
}
|
|
|
|
runEditorAction((ctx) => {
|
|
const commands = ctx.get(commandsCtx);
|
|
commands.call(addBlockTypeCommand.key, {
|
|
nodeType: richImageBlockSchema.type(ctx),
|
|
attrs: {
|
|
src: resolvedSrc,
|
|
caption: "",
|
|
assetId: payload.assetId ?? 0,
|
|
ratio: 1,
|
|
width: 0,
|
|
height: 0,
|
|
align: "center",
|
|
},
|
|
});
|
|
|
|
requestAnimationFrame(() => {
|
|
ctx.get(editorViewCtx).focus();
|
|
});
|
|
});
|
|
}
|
|
|
|
function insertImage(): void {
|
|
if (editorDisabled.value) {
|
|
return;
|
|
}
|
|
|
|
closeTablePicker();
|
|
closeTableContextMenu();
|
|
closeImageContextMenu();
|
|
imagePickerAction.value = "insert";
|
|
imagePickerCurrentUrl.value = "";
|
|
imagePickerCurrentAssetId.value = null;
|
|
imagePickerOpen.value = true;
|
|
}
|
|
|
|
async function uploadBodyImageToLibrary(file: File): Promise<{ url: string; fileName: string; assetId: number }> {
|
|
const uploaded = await imagesApi.upload(file);
|
|
return {
|
|
url: uploaded.url,
|
|
fileName: uploaded.name,
|
|
assetId: uploaded.id,
|
|
};
|
|
}
|
|
|
|
function handleImagePicked(payload: { url: string; fileName: string; assetId?: number | null }): void {
|
|
const assetId = payload.assetId ?? null;
|
|
if (imagePickerAction.value === "replace" && selectedImage.value.open) {
|
|
updateSelectedImageAttributes({
|
|
src: payload.url,
|
|
assetId: assetId ?? 0,
|
|
ratio: 1,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
} else {
|
|
insertImageBlockAtCursor({
|
|
src: payload.url,
|
|
assetId,
|
|
});
|
|
}
|
|
|
|
imagePickerAction.value = "insert";
|
|
imagePickerCurrentUrl.value = "";
|
|
imagePickerCurrentAssetId.value = null;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function resolveImageAlign(value: unknown): RichImageAlign {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
if (normalized === "left" || normalized === "right") {
|
|
return normalized;
|
|
}
|
|
return "center";
|
|
}
|
|
|
|
function closeTablePicker(): void {
|
|
tablePickerOpen.value = false;
|
|
}
|
|
|
|
function assignTablePickerRef(element: Element | ComponentPublicInstance | null): void {
|
|
tablePickerRef.value = element instanceof HTMLElement ? element : null;
|
|
}
|
|
|
|
function assignSurfaceRef(element: Element | ComponentPublicInstance | null): void {
|
|
surfaceRef.value = element instanceof HTMLElement ? element : null;
|
|
}
|
|
|
|
function assignEditorRootRef(element: Element | ComponentPublicInstance | null): void {
|
|
editorRootRef.value = element instanceof HTMLElement ? element : null;
|
|
}
|
|
|
|
function closeTableContextMenu(): void {
|
|
tableContextMenu.value = createDefaultTableContextMenuState();
|
|
}
|
|
|
|
function resolveSurfaceRelativeRect(rect: DOMRect): { x: number; y: number; width: number; height: number } | null {
|
|
const surface = surfaceRef.value;
|
|
if (!surface) {
|
|
return null;
|
|
}
|
|
|
|
const surfaceRect = surface.getBoundingClientRect();
|
|
|
|
return {
|
|
x: rect.left - surfaceRect.left + surface.scrollLeft,
|
|
y: rect.top - surfaceRect.top + surface.scrollTop,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
};
|
|
}
|
|
|
|
function detachSelectedImageDomSync(): void {
|
|
selectedImageResizeObserver?.disconnect();
|
|
selectedImageResizeObserver = null;
|
|
|
|
if (selectedImageElement && selectedImageLoadHandler) {
|
|
selectedImageElement.removeEventListener("load", selectedImageLoadHandler);
|
|
}
|
|
selectedImageLoadHandler = null;
|
|
}
|
|
|
|
function queueSelectedImageBoundsSync(): void {
|
|
requestAnimationFrame(() => {
|
|
syncSelectedImageBoundsFromDom();
|
|
requestAnimationFrame(() => {
|
|
syncSelectedImageBoundsFromDom();
|
|
});
|
|
});
|
|
}
|
|
|
|
function attachSelectedImageDomSync(): void {
|
|
detachSelectedImageDomSync();
|
|
|
|
if (!selectedImageElement) {
|
|
return;
|
|
}
|
|
|
|
selectedImageLoadHandler = () => {
|
|
queueSelectedImageBoundsSync();
|
|
};
|
|
selectedImageElement.addEventListener("load", selectedImageLoadHandler);
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
selectedImageResizeObserver = new ResizeObserver(() => {
|
|
syncSelectedImageBoundsFromDom();
|
|
});
|
|
selectedImageResizeObserver.observe(selectedImageElement);
|
|
}
|
|
|
|
queueSelectedImageBoundsSync();
|
|
}
|
|
|
|
function clearSelectedImageState(): void {
|
|
detachSelectedImageDomSync();
|
|
selectedImage.value = createDefaultSelectedImageState();
|
|
selectedImageHostElement = null;
|
|
selectedImageWrapperElement = null;
|
|
selectedImageElement = null;
|
|
}
|
|
|
|
function closeImageContextMenu(): void {
|
|
imageContextMenu.value = createDefaultImageContextMenuState();
|
|
}
|
|
|
|
function resolveEditorDOMRange(view: EditorView, from: number, to: number): Range | null {
|
|
try {
|
|
const start = view.domAtPos(from);
|
|
const end = view.domAtPos(to);
|
|
const range = view.dom.ownerDocument.createRange();
|
|
range.setStart(start.node, start.offset);
|
|
range.setEnd(end.node, end.offset);
|
|
return range;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getCSSHighlightsRegistry():
|
|
| {
|
|
set: (name: string, value: unknown) => void;
|
|
delete: (name: string) => void;
|
|
}
|
|
| null {
|
|
const cssWithHighlights = globalThis.CSS as typeof globalThis.CSS & {
|
|
highlights?: {
|
|
set: (name: string, value: unknown) => void;
|
|
delete: (name: string) => void;
|
|
};
|
|
};
|
|
|
|
return cssWithHighlights.highlights ?? null;
|
|
}
|
|
|
|
function clearAiOptimizeSelectionHighlight(): void {
|
|
getCSSHighlightsRegistry()?.delete(AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME);
|
|
}
|
|
|
|
function syncAiOptimizeSelectionHighlight(from: number, to: number): void {
|
|
clearAiOptimizeSelectionHighlight();
|
|
|
|
const registry = getCSSHighlightsRegistry();
|
|
const HighlightCtor = window.Highlight as (new (...ranges: Range[]) => unknown) | undefined;
|
|
if (!registry || !HighlightCtor) {
|
|
return;
|
|
}
|
|
|
|
const editor = getEditor();
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
|
|
editor.action((ctx) => {
|
|
const view = ctx.get(editorViewCtx);
|
|
const range = resolveEditorDOMRange(view, from, to);
|
|
if (!range) {
|
|
return;
|
|
}
|
|
|
|
registry.set(AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME, new HighlightCtor(range));
|
|
});
|
|
}
|
|
|
|
function resetAiOptimizeOutput(): void {
|
|
aiOptimizePreview.value = "";
|
|
aiOptimizeError.value = "";
|
|
aiOptimizeStatus.value = "idle";
|
|
}
|
|
|
|
function stopAiOptimizeGeneration(): void {
|
|
aiOptimizeAbortController?.abort();
|
|
aiOptimizeAbortController = null;
|
|
|
|
if (aiOptimizeStatus.value === "generating") {
|
|
aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "idle";
|
|
aiOptimizeError.value = "";
|
|
}
|
|
}
|
|
|
|
function closeAiOptimizePanel(): void {
|
|
stopAiOptimizeGeneration();
|
|
clearAiOptimizeSelectionHighlight();
|
|
aiOptimizePanel.value = createDefaultAiOptimizePanelState();
|
|
aiOptimizeInstruction.value = "";
|
|
resetAiOptimizeOutput();
|
|
}
|
|
|
|
function resolveTextSelectionSnapshot(ctx: Ctx): TextSelectionSnapshot | null {
|
|
const view = ctx.get(editorViewCtx);
|
|
const { selection, doc } = view.state;
|
|
if (!(selection instanceof TextSelection) || selection.empty) {
|
|
return null;
|
|
}
|
|
|
|
const text = doc.textBetween(selection.from, selection.to, "\n\n");
|
|
if (!text.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const range = resolveEditorDOMRange(view, selection.from, selection.to);
|
|
const rangeRects = range
|
|
? Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0)
|
|
: [];
|
|
const rangeRect =
|
|
rangeRects.length > 0
|
|
? rangeRects.reduce(
|
|
(combined, rect) => {
|
|
const left = Math.min(combined.left, rect.left);
|
|
const top = Math.min(combined.top, rect.top);
|
|
const right = Math.max(combined.right, rect.right);
|
|
const bottom = Math.max(combined.bottom, rect.bottom);
|
|
return new DOMRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1));
|
|
},
|
|
new DOMRect(
|
|
rangeRects[0].left,
|
|
rangeRects[0].top,
|
|
Math.max(rangeRects[0].width, 1),
|
|
Math.max(rangeRects[0].height, 1),
|
|
),
|
|
)
|
|
: range?.getBoundingClientRect();
|
|
const rect =
|
|
rangeRect && (rangeRect.width > 0 || rangeRect.height > 0)
|
|
? rangeRect
|
|
: (() => {
|
|
const start = view.coordsAtPos(selection.from);
|
|
const end = view.coordsAtPos(selection.to);
|
|
const left = Math.min(start.left, end.left);
|
|
const right = Math.max(start.right, end.right);
|
|
const top = Math.min(start.top, end.top);
|
|
const bottom = Math.max(start.bottom, end.bottom);
|
|
return new DOMRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1));
|
|
})();
|
|
|
|
return {
|
|
from: selection.from,
|
|
to: selection.to,
|
|
text,
|
|
rect,
|
|
};
|
|
}
|
|
|
|
function resolveAiOptimizePanelFrame(anchorRect: DOMRect): { x: number; y: number; width: number; placement: EditorAiAssistPlacement } {
|
|
const viewportPadding = 16;
|
|
const panelPadding = 24;
|
|
const maxWidth = Math.max(240, window.innerWidth - viewportPadding * 2);
|
|
const preferredWidth = 1120;
|
|
|
|
const surfaceRect = surfaceRef.value?.getBoundingClientRect();
|
|
const boundary = surfaceRect ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
|
const widthFromSurface = surfaceRect ? Math.max(240, surfaceRect.width - panelPadding * 2) : preferredWidth;
|
|
const width = Math.min(maxWidth, preferredWidth, widthFromSurface);
|
|
const availableBelow = boundary.bottom - anchorRect.bottom - AI_OPTIMIZE_PANEL_GAP - viewportPadding;
|
|
const availableAbove = anchorRect.top - boundary.top - AI_OPTIMIZE_PANEL_GAP - viewportPadding;
|
|
const placement: EditorAiAssistPlacement =
|
|
availableBelow >= 320 || availableBelow >= availableAbove ? "bottom" : "top";
|
|
|
|
return {
|
|
x: anchorRect.left + anchorRect.width / 2,
|
|
y:
|
|
placement === "top"
|
|
? Math.min(boundary.bottom - viewportPadding, anchorRect.top - AI_OPTIMIZE_PANEL_GAP)
|
|
: Math.max(boundary.top + viewportPadding, anchorRect.bottom + AI_OPTIMIZE_PANEL_GAP),
|
|
width,
|
|
placement,
|
|
};
|
|
}
|
|
|
|
function openAiOptimizePanel(ctx: Ctx): void {
|
|
if (editorDisabled.value) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = resolveTextSelectionSnapshot(ctx);
|
|
if (!snapshot) {
|
|
message.warning(t("article.editor.aiOptimize.messages.selectionRequired"));
|
|
return;
|
|
}
|
|
|
|
closeTablePicker();
|
|
closeTableContextMenu();
|
|
clearSelectedImageState();
|
|
closeImageContextMenu();
|
|
stopAiOptimizeGeneration();
|
|
const frame = resolveAiOptimizePanelFrame(snapshot.rect);
|
|
|
|
aiOptimizePanel.value = {
|
|
open: true,
|
|
x: frame.x,
|
|
y: frame.y,
|
|
width: frame.width,
|
|
placement: frame.placement,
|
|
from: snapshot.from,
|
|
to: snapshot.to,
|
|
selectedText: snapshot.text,
|
|
};
|
|
aiOptimizeInstruction.value = "";
|
|
resetAiOptimizeOutput();
|
|
aiOptimizeIgnoreNextWindowPointerDown = true;
|
|
|
|
window.setTimeout(() => {
|
|
aiOptimizeIgnoreNextWindowPointerDown = false;
|
|
}, 0);
|
|
|
|
void nextTick(() => {
|
|
syncAiOptimizeSelectionHighlight(snapshot.from, snapshot.to);
|
|
});
|
|
}
|
|
|
|
function handleAiOptimizeStreamEvent(event: ArticleSelectionOptimizeStreamEvent): void {
|
|
if (event.type === "start") {
|
|
aiOptimizeStatus.value = "generating";
|
|
aiOptimizeError.value = "";
|
|
return;
|
|
}
|
|
|
|
if (event.type === "delta") {
|
|
aiOptimizeStatus.value = "generating";
|
|
aiOptimizeError.value = "";
|
|
if (typeof event.content === "string") {
|
|
aiOptimizePreview.value = event.content;
|
|
} else if (typeof event.delta === "string") {
|
|
aiOptimizePreview.value += event.delta;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type === "completed") {
|
|
aiOptimizeStatus.value = "completed";
|
|
aiOptimizeError.value = "";
|
|
if (typeof event.content === "string") {
|
|
aiOptimizePreview.value = event.content;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type === "error") {
|
|
aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error";
|
|
aiOptimizeError.value = event.error || t("article.editor.aiOptimize.messages.failed");
|
|
if (typeof event.content === "string" && event.content.trim()) {
|
|
aiOptimizePreview.value = event.content;
|
|
}
|
|
if (!aiOptimizeHasPreview.value) {
|
|
message.error(aiOptimizeError.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generateAiOptimizePreview(): Promise<void> {
|
|
if (!aiOptimizePanel.value.open || editorDisabled.value) {
|
|
return;
|
|
}
|
|
|
|
const instruction = aiOptimizeInstruction.value.trim();
|
|
if (!instruction) {
|
|
message.warning(t("article.editor.aiOptimize.messages.promptRequired"));
|
|
return;
|
|
}
|
|
|
|
stopAiOptimizeGeneration();
|
|
aiOptimizePreview.value = "";
|
|
aiOptimizeError.value = "";
|
|
aiOptimizeStatus.value = "generating";
|
|
|
|
const controller = new AbortController();
|
|
aiOptimizeAbortController = controller;
|
|
|
|
try {
|
|
await streamArticleSelectionOptimize(
|
|
props.articleId,
|
|
{
|
|
title: props.title,
|
|
markdown_content: props.modelValue,
|
|
selected_text: aiOptimizePanel.value.selectedText,
|
|
instruction,
|
|
},
|
|
{
|
|
onEvent: handleAiOptimizeStreamEvent,
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
if (!controller.signal.aborted && aiOptimizeStatus.value === "generating") {
|
|
aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error";
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error";
|
|
aiOptimizeError.value = formatError(error);
|
|
if (!aiOptimizeHasPreview.value) {
|
|
message.error(aiOptimizeError.value);
|
|
}
|
|
} finally {
|
|
if (aiOptimizeAbortController === controller) {
|
|
aiOptimizeAbortController = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyAiOptimizeReplacement(): void {
|
|
const replacement = aiOptimizePreview.value.trim();
|
|
if (!aiOptimizePanel.value.open || !replacement) {
|
|
if (!replacement) {
|
|
message.warning(t("article.editor.aiOptimize.messages.empty"));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const target = { ...aiOptimizePanel.value };
|
|
let replaced = false;
|
|
|
|
runEditorAction((ctx) => {
|
|
const view = ctx.get(editorViewCtx);
|
|
const currentText = view.state.doc.textBetween(target.from, target.to, "\n\n");
|
|
if (currentText !== target.selectedText) {
|
|
message.warning(t("article.editor.aiOptimize.messages.selectionChanged"));
|
|
return;
|
|
}
|
|
|
|
const parser = ctx.get(parserCtx);
|
|
const selection = TextSelection.create(view.state.doc, target.from, target.to);
|
|
|
|
try {
|
|
const replacementDoc = parser(replacement);
|
|
const replacementSlice = Slice.maxOpen(replacementDoc.content);
|
|
const transaction = view.state.tr.setSelection(selection).replaceSelection(replacementSlice).scrollIntoView();
|
|
view.dispatch(transaction);
|
|
} catch {
|
|
view.dispatch(view.state.tr.insertText(replacement, target.from, target.to).scrollIntoView());
|
|
}
|
|
|
|
replaced = true;
|
|
requestAnimationFrame(() => {
|
|
view.focus();
|
|
});
|
|
});
|
|
|
|
if (replaced) {
|
|
closeAiOptimizePanel();
|
|
}
|
|
}
|
|
|
|
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 = image.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 ?? ""),
|
|
assetId: resolveImageAssetId(selection.node.attrs.assetId),
|
|
align: resolveImageAlign(selection.node.attrs.align),
|
|
};
|
|
});
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): void {
|
|
if (!snapshot) {
|
|
clearSelectedImageState();
|
|
closeImageContextMenu();
|
|
return;
|
|
}
|
|
|
|
selectedImageHostElement = snapshot.host;
|
|
selectedImageWrapperElement = snapshot.wrapper;
|
|
selectedImageElement = snapshot.image;
|
|
const surfaceRect = resolveSurfaceRelativeRect(snapshot.rect);
|
|
if (!surfaceRect) {
|
|
clearSelectedImageState();
|
|
closeImageContextMenu();
|
|
return;
|
|
}
|
|
|
|
selectedImage.value = {
|
|
open: true,
|
|
x: surfaceRect.x,
|
|
y: surfaceRect.y,
|
|
width: surfaceRect.width,
|
|
height: surfaceRect.height,
|
|
nodePos: snapshot.nodePos,
|
|
nodeSize: snapshot.nodeSize,
|
|
src: snapshot.src,
|
|
assetId: snapshot.assetId,
|
|
align: snapshot.align,
|
|
};
|
|
attachSelectedImageDomSync();
|
|
|
|
if (imageContextMenu.value.open && imageContextMenu.value.nodePos !== snapshot.nodePos) {
|
|
closeImageContextMenu();
|
|
}
|
|
}
|
|
|
|
function syncSelectedImageBoundsFromDom(): void {
|
|
const frameElement = selectedImageElement ?? selectedImageWrapperElement;
|
|
if (!selectedImage.value.open || !frameElement) {
|
|
return;
|
|
}
|
|
|
|
if (!document.body.contains(frameElement)) {
|
|
clearSelectedImageState();
|
|
closeImageContextMenu();
|
|
return;
|
|
}
|
|
|
|
const rect = frameElement.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) {
|
|
return;
|
|
}
|
|
|
|
const surfaceRect = resolveSurfaceRelativeRect(rect);
|
|
if (!surfaceRect) {
|
|
return;
|
|
}
|
|
|
|
selectedImage.value = {
|
|
...selectedImage.value,
|
|
x: surfaceRect.x,
|
|
y: surfaceRect.y,
|
|
width: surfaceRect.width,
|
|
height: surfaceRect.height,
|
|
};
|
|
}
|
|
|
|
function scheduleSelectedImageSync(selection?: Selection | null): void {
|
|
if (imageDragActive) {
|
|
return;
|
|
}
|
|
|
|
if (selectedImageSyncFrame) {
|
|
cancelAnimationFrame(selectedImageSyncFrame);
|
|
}
|
|
|
|
selectedImageSyncFrame = requestAnimationFrame(() => {
|
|
selectedImageSyncFrame = 0;
|
|
applySelectedImageSnapshot(resolveSelectedImageSnapshot(selection));
|
|
});
|
|
}
|
|
|
|
function queueSelectedImageSync(): void {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
scheduleSelectedImageSync();
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
tablePickerOpen.value = true;
|
|
}
|
|
|
|
function handleWindowPointerDown(event: PointerEvent): void {
|
|
if (aiOptimizeIgnoreNextWindowPointerDown) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!tablePickerOpen.value &&
|
|
!tableContextMenu.value.open &&
|
|
!imageContextMenu.value.open &&
|
|
!(aiOptimizePanel.value.open && aiOptimizeStatus.value === "idle")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (target instanceof Node) {
|
|
if (tablePickerOpen.value && tablePickerRef.value?.contains(target)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isEditorActionMenuDropdownTarget(target)) {
|
|
return;
|
|
}
|
|
|
|
if (tablePickerOpen.value) {
|
|
closeTablePicker();
|
|
}
|
|
|
|
if (tableContextMenu.value.open) {
|
|
closeTableContextMenu();
|
|
}
|
|
|
|
if (imageContextMenu.value.open) {
|
|
closeImageContextMenu();
|
|
}
|
|
|
|
if (aiOptimizePanel.value.open && aiOptimizeStatus.value === "idle") {
|
|
closeAiOptimizePanel();
|
|
}
|
|
}
|
|
|
|
function isEditorActionMenuDropdownTarget(target: EventTarget | null): boolean {
|
|
if (target instanceof Element) {
|
|
return target.closest(EDITOR_ACTION_MENU_DROPDOWN_SELECTOR) !== null;
|
|
}
|
|
|
|
if (target instanceof Node) {
|
|
return target.parentElement?.closest(EDITOR_ACTION_MENU_DROPDOWN_SELECTOR) !== null;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 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,
|
|
};
|
|
}
|
|
|
|
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: image.getBoundingClientRect(),
|
|
nodePos,
|
|
nodeSize: node.nodeSize,
|
|
src: String(node.attrs.src ?? ""),
|
|
assetId: resolveImageAssetId(node.attrs.assetId),
|
|
align: resolveImageAlign(node.attrs.align),
|
|
};
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function handleEditorDragStart(event: DragEvent): void {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement) || !target.closest(".milkdown-image-block")) {
|
|
return;
|
|
}
|
|
|
|
imageDragActive = true;
|
|
if (selectedImageSyncFrame) {
|
|
cancelAnimationFrame(selectedImageSyncFrame);
|
|
selectedImageSyncFrame = 0;
|
|
}
|
|
closeImageContextMenu();
|
|
clearSelectedImageState();
|
|
}
|
|
|
|
function finishEditorImageDrag(): void {
|
|
if (!imageDragActive) {
|
|
return;
|
|
}
|
|
|
|
imageDragActive = false;
|
|
queueSelectedImageSync();
|
|
}
|
|
|
|
function handleEditorDragEnd(): void {
|
|
finishEditorImageDrag();
|
|
}
|
|
|
|
function handleEditorDrop(): void {
|
|
finishEditorImageDrag();
|
|
}
|
|
|
|
function updateSelectedImageAttributes(
|
|
attrs: Partial<Record<"src" | "assetId" | "ratio" | "width" | "height" | "align", string | number>>,
|
|
): void {
|
|
if (!selectedImage.value.open) {
|
|
return;
|
|
}
|
|
|
|
const { nodePos } = selectedImage.value;
|
|
runEditorAction((ctx) => {
|
|
const view = ctx.get(editorViewCtx);
|
|
let transaction = view.state.tr;
|
|
for (const [attr, value] of Object.entries(attrs)) {
|
|
transaction = transaction.setNodeAttribute(nodePos, attr, value);
|
|
}
|
|
view.dispatch(transaction);
|
|
requestAnimationFrame(() => {
|
|
view.focus();
|
|
scheduleSelectedImageSync();
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateSelectedImageAttribute(
|
|
attr: "src" | "assetId" | "ratio" | "width" | "height" | "align",
|
|
value: string | number,
|
|
): void {
|
|
updateSelectedImageAttributes({ [attr]: value });
|
|
}
|
|
|
|
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();
|
|
imagePickerAction.value = "replace";
|
|
imagePickerCurrentUrl.value = selectedImage.value.src;
|
|
imagePickerCurrentAssetId.value = selectedImage.value.assetId;
|
|
imagePickerOpen.value = true;
|
|
}
|
|
|
|
function runImageContextAction(action: ImageContextMenuAction): void {
|
|
switch (action) {
|
|
case "replace-image":
|
|
replaceSelectedImage();
|
|
break;
|
|
case "toggle-caption":
|
|
toggleSelectedImageCaption();
|
|
break;
|
|
case "reset-size":
|
|
closeImageContextMenu();
|
|
updateSelectedImageAttributes({
|
|
ratio: 1,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
break;
|
|
case "align-left":
|
|
closeImageContextMenu();
|
|
updateSelectedImageAttribute("align", "left");
|
|
break;
|
|
case "align-center":
|
|
closeImageContextMenu();
|
|
updateSelectedImageAttribute("align", "center");
|
|
break;
|
|
case "align-right":
|
|
closeImageContextMenu();
|
|
updateSelectedImageAttribute("align", "right");
|
|
break;
|
|
case "delete-image":
|
|
deleteSelectedImage();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleTableContextMenuSelect(action: string): void {
|
|
runTableContextAction(action as TableContextMenuAction);
|
|
}
|
|
|
|
function handleImageContextMenuSelect(action: string): void {
|
|
runImageContextAction(action as ImageContextMenuAction);
|
|
}
|
|
|
|
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 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 nextWidth = Number((startWidth * scale).toFixed(2));
|
|
const nextHeight = Number((startHeight * scale).toFixed(2));
|
|
|
|
image.dataset.width = String(nextWidth);
|
|
image.dataset.height = String(nextHeight);
|
|
image.style.width = `${nextWidth}px`;
|
|
image.style.height = `${nextHeight}px`;
|
|
syncSelectedImageBoundsFromDom();
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
window.removeEventListener("pointermove", onPointerMove);
|
|
window.removeEventListener("pointerup", onPointerUp);
|
|
body.style.cursor = "";
|
|
|
|
const currentWidth = Number(image.dataset.width || image.getBoundingClientRect().width);
|
|
const currentHeight = Number(image.dataset.height || image.getBoundingClientRect().height);
|
|
if (Number.isNaN(currentWidth) || currentWidth <= 0 || Number.isNaN(currentHeight) || currentHeight <= 0) {
|
|
scheduleSelectedImageSync();
|
|
return;
|
|
}
|
|
|
|
updateSelectedImageAttributes({
|
|
ratio: 1,
|
|
width: currentWidth,
|
|
height: currentHeight,
|
|
});
|
|
};
|
|
|
|
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();
|
|
if (aiOptimizePanel.value.open) {
|
|
syncAiOptimizeSelectionHighlight(aiOptimizePanel.value.from, aiOptimizePanel.value.to);
|
|
}
|
|
}
|
|
|
|
function handleWindowResize(): void {
|
|
syncSelectedImageBoundsFromDom();
|
|
if (aiOptimizePanel.value.open) {
|
|
syncAiOptimizeSelectionHighlight(aiOptimizePanel.value.from, aiOptimizePanel.value.to);
|
|
}
|
|
}
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent): void {
|
|
if (!aiOptimizePanel.value.open) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeAiOptimizePanel();
|
|
}
|
|
}
|
|
|
|
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,
|
|
'article-editor-canvas--ai-optimize-open': aiOptimizePanel.open,
|
|
}"
|
|
>
|
|
<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="assignTablePickerRef" 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>
|
|
|
|
<EditorGridSizePicker
|
|
v-if="tablePickerOpen"
|
|
:empty-label="t('article.editor.tablePicker.empty')"
|
|
:hint="t('article.editor.tablePicker.hint')"
|
|
@select="insertTableFromSelection"
|
|
/>
|
|
</div>
|
|
|
|
<span class="article-editor-canvas__divider"></span>
|
|
<a-button
|
|
size="small"
|
|
type="text"
|
|
@mousedown.prevent
|
|
@click="insertImage"
|
|
>
|
|
<template #icon><PictureOutlined /></template>
|
|
</a-button>
|
|
</div>
|
|
|
|
<div
|
|
:ref="assignSurfaceRef"
|
|
class="article-editor-canvas__surface"
|
|
@contextmenu.capture="handleEditorContextMenu"
|
|
@dragstart.capture="handleEditorDragStart"
|
|
@dragend.capture="handleEditorDragEnd"
|
|
@drop.capture="handleEditorDrop"
|
|
@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>
|
|
|
|
<div :ref="assignEditorRootRef" data-milkdown-root></div>
|
|
|
|
<EditorSelectionFrame
|
|
v-if="selectedImage.open"
|
|
:x="selectedImage.x"
|
|
:y="selectedImage.y"
|
|
:width="selectedImage.width"
|
|
:height="selectedImage.height"
|
|
@resize-start="beginSelectedImageResize"
|
|
/>
|
|
|
|
<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>
|
|
|
|
<EditorActionMenu
|
|
v-if="tableContextMenu.open"
|
|
:x="tableContextMenu.x"
|
|
:y="tableContextMenu.y"
|
|
:groups="tableContextMenuGroups"
|
|
variant="inline"
|
|
@select="handleTableContextMenuSelect"
|
|
/>
|
|
|
|
<EditorActionMenu
|
|
v-if="imageContextMenu.open"
|
|
:x="imageContextMenu.x"
|
|
:y="imageContextMenu.y"
|
|
:groups="imageContextMenuGroups"
|
|
variant="inline"
|
|
@select="handleImageContextMenuSelect"
|
|
/>
|
|
|
|
<EditorAiAssistPanel
|
|
v-if="aiOptimizePanel.open"
|
|
v-model:prompt="aiOptimizeInstruction"
|
|
:x="aiOptimizePanel.x"
|
|
:y="aiOptimizePanel.y"
|
|
:width="aiOptimizePanel.width"
|
|
:placement="aiOptimizePanel.placement"
|
|
:boundary-element="surfaceRef"
|
|
:status="aiOptimizeStatus"
|
|
:streaming="aiOptimizeStreaming"
|
|
:has-preview="aiOptimizeHasPreview"
|
|
:error="aiOptimizeError"
|
|
:title="aiOptimizeUiText.title"
|
|
:prompt-placeholder="aiOptimizeUiText.promptPlaceholder"
|
|
:continue-placeholder="aiOptimizeUiText.promptPlaceholder"
|
|
:loading-label="aiOptimizeUiText.generating"
|
|
:error-label="aiOptimizeUiText.failed"
|
|
:disclaimer="aiOptimizeUiText.disclaimer"
|
|
:regenerate-label="aiOptimizeUiText.regenerate"
|
|
:discard-label="aiOptimizeUiText.close"
|
|
:apply-label="aiOptimizeUiText.replace"
|
|
@submit="generateAiOptimizePreview"
|
|
@close="closeAiOptimizePanel"
|
|
@reset="resetAiOptimizeOutput"
|
|
@apply="applyAiOptimizeReplacement"
|
|
>
|
|
<template #preview>
|
|
<MarkdownPreview :content="aiOptimizePreview" />
|
|
</template>
|
|
</EditorAiAssistPanel>
|
|
|
|
<CoverPickerModal
|
|
v-model:open="imagePickerOpen"
|
|
:article-id="articleId"
|
|
:platform-ids="[]"
|
|
:current-url="imagePickerCurrentUrl"
|
|
:current-asset-id="imagePickerCurrentAssetId"
|
|
:title="t('article.editor.imagePicker.title')"
|
|
:empty-upload-title="t('article.editor.imagePicker.emptyTitle')"
|
|
:empty-upload-hint="t('article.editor.imagePicker.emptyHint')"
|
|
:upload-handler="uploadBodyImageToLibrary"
|
|
@confirmed="handleImagePicked"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:global(::highlight(article-editor-ai-optimize-selection)) {
|
|
color: inherit;
|
|
background: rgba(156, 163, 175, 0.42);
|
|
}
|
|
|
|
.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__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) {
|
|
margin: 18px 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.article-editor-canvas__surface :deep(.milkdown-image-block[data-align="left"]) {
|
|
text-align: left;
|
|
}
|
|
|
|
.article-editor-canvas__surface :deep(.milkdown-image-block[data-align="right"]) {
|
|
text-align: right;
|
|
}
|
|
|
|
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper) {
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.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) {
|
|
display: block;
|
|
max-width: min(100%, 960px);
|
|
border-radius: 18px;
|
|
}
|
|
|
|
.article-editor-canvas__surface :deep(.milkdown-image-block .caption-input) {
|
|
width: min(100%, 960px);
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.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__surface :deep(.milkdown-table-block table) {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
border-spacing: 0;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.article-editor-canvas__surface :deep(.milkdown-table-block th),
|
|
.article-editor-canvas__surface :deep(.milkdown-table-block td) {
|
|
min-width: 140px;
|
|
min-height: 58px;
|
|
padding: 12px 18px;
|
|
border: 2px solid #d7d7d7;
|
|
color: #1f2937;
|
|
caret-color: #000000;
|
|
font-size: 16px;
|
|
line-height: 1.55;
|
|
vertical-align: middle;
|
|
background: #ffffff;
|
|
}
|
|
|
|
|
|
|
|
.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;
|
|
}
|
|
|
|
.article-editor-canvas--ai-optimize-open :deep(.milkdown-toolbar) {
|
|
display: none !important;
|
|
}
|
|
</style>
|