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

679 lines
19 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
BoldOutlined,
CodeOutlined,
ItalicOutlined,
StrikethroughOutlined,
OrderedListOutlined,
MinusOutlined,
PictureOutlined,
RedoOutlined,
TableOutlined,
UndoOutlined,
UnorderedListOutlined,
} from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
import {
insertHrCommand,
insertImageCommand,
toggleEmphasisCommand,
toggleStrongCommand,
toggleInlineCodeCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
wrapInOrderedListCommand,
} from "@milkdown/kit/preset/commonmark";
import { insertTableCommand, toggleStrikethroughCommand } from "@milkdown/kit/preset/gfm";
import { callCommand, replaceAll } from "@milkdown/kit/utils";
import { Crepe, CrepeFeature } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { formatError } from "@/lib/errors";
const props = defineProps<{
title: string;
modelValue: string;
disabled?: boolean;
uploadImage?: (file: File) => Promise<string>;
}>();
const emit = defineEmits<{
"update:title": [value: string];
"update:modelValue": [value: string];
}>();
const { t } = useI18n();
const crepeRef = ref<Crepe | null>(null);
const syncingExternalMarkdown = ref(false);
const tablePickerRef = ref<HTMLElement | null>(null);
const tableGridRef = ref<HTMLElement | null>(null);
const 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;
const { loading } = useEditor((root) => {
const crepe = new Crepe({
root,
defaultValue: props.modelValue || "",
features: {
[CrepeFeature.TopBar]: false,
[CrepeFeature.Toolbar]: true,
[CrepeFeature.Latex]: false,
[CrepeFeature.BlockEdit]: false,
[CrepeFeature.Placeholder]: false,
},
});
crepe.setReadonly(!!props.disabled);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
if (syncingExternalMarkdown.value) {
return;
}
emit("update:modelValue", markdown);
});
});
crepeRef.value = crepe;
return crepe;
});
const [instanceLoading, getEditor] = useInstance();
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
const tablePickerOpen = ref(false);
const tablePickerRows = ref(TABLE_PICKER_INITIAL_ROWS);
const tablePickerCols = ref(TABLE_PICKER_INITIAL_COLS);
const tablePreviewRow = ref(0);
const tablePreviewCol = ref(0);
const tablePointerActive = ref(false);
const imageUploading = ref(false);
const 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}`;
});
function syncMarkdownFromProps(nextValue: string): void {
const editor = getEditor();
const crepe = crepeRef.value;
if (!editor || !crepe) {
return;
}
const resolvedValue = nextValue || "";
if (crepe.getMarkdown() === resolvedValue) {
return;
}
syncingExternalMarkdown.value = true;
editor.action(replaceAll(resolvedValue, true));
queueMicrotask(() => {
syncingExternalMarkdown.value = false;
});
}
watch(
() => props.disabled,
(value) => {
crepeRef.value?.setReadonly(!!value);
if (value) {
closeTablePicker();
}
},
{ immediate: true },
);
watch(
[() => props.modelValue, loading, instanceLoading],
([value, editorLoading, currentInstanceLoading]) => {
if (editorLoading || currentInstanceLoading) {
return;
}
syncMarkdownFromProps(value);
},
{ immediate: true },
);
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleWindowPointerDown);
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
crepeRef.value = null;
});
onMounted(() => {
window.addEventListener("pointerdown", handleWindowPointerDown);
window.addEventListener("pointermove", handleWindowPointerMove);
window.addEventListener("pointerup", handleWindowPointerUp);
});
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 insertImage(): void {
if (editorDisabled.value) {
return;
}
if (props.uploadImage) {
imageInputRef.value?.click();
return;
}
const src = window.prompt(t("article.editor.imagePrompt"));
if (!src?.trim()) {
return;
}
runCommand(insertImageCommand, { src: src.trim() });
}
async function handleImageFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
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");
}
runCommand(insertImageCommand, {
src,
alt: resolveImageAltText(file.name),
});
} catch (error) {
message.error(formatError(error));
} finally {
imageUploading.value = false;
}
}
function resolveImageAltText(fileName: string): string {
return fileName.replace(/\.[^.]+$/, "").trim() || "image";
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function resetTablePicker(): void {
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
tablePreviewRow.value = 0;
tablePreviewCol.value = 0;
tablePointerActive.value = false;
}
function closeTablePicker(): void {
tablePickerOpen.value = false;
resetTablePicker();
}
function 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;
}
resetTablePicker();
tablePickerOpen.value = true;
}
function beginTableSelection(row: number, col: number): void {
if (editorDisabled.value) {
return;
}
tablePointerActive.value = true;
updateTablePreview(row, col);
}
function handleWindowPointerDown(event: PointerEvent): void {
if (!tablePickerOpen.value) {
return;
}
const target = event.target;
if (target instanceof Node && tablePickerRef.value?.contains(target)) {
return;
}
closeTablePicker();
}
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);
}
</script>
<template>
<div class="article-editor-canvas" :class="{ 'article-editor-canvas--disabled': disabled }">
<div class="article-editor-canvas__toolbar">
<a-button size="small" type="text" @click="runCommand(undoCommand)">
<template #icon><IconFont type="icon-Undo" /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(redoCommand)">
<template #icon><IconFont type="icon-redo" /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 1)">H1</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 2)">H2</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 3)">H3</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(toggleStrongCommand)">
<template #icon><BoldOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(toggleEmphasisCommand)">
<template #icon><ItalicOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(toggleStrikethroughCommand)">
<template #icon><StrikethroughOutlined /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" style="font-size: 16px; font-weight: bold; line-height: 1" @click="runCommand(wrapInBlockquoteCommand)">
"
</a-button>
<a-button size="small" type="text" @click="runCommand(insertHrCommand)">
<template #icon><MinusOutlined /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(wrapInBulletListCommand)">
<template #icon><UnorderedListOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
<template #icon><OrderedListOutlined /></template>
</a-button>
<div ref="tablePickerRef" class="article-editor-canvas__table-trigger">
<a-button
size="small"
type="text"
class="article-editor-canvas__toolbar-button"
:class="{ 'article-editor-canvas__toolbar-button--active': tablePickerOpen }"
@click="toggleTablePicker"
>
<template #icon><TableOutlined /></template>
</a-button>
<div v-if="tablePickerOpen" class="article-editor-canvas__table-panel" @pointerdown.stop>
<div class="article-editor-canvas__table-panel-status">{{ tableSelectionLabel }}</div>
<div
ref="tableGridRef"
class="article-editor-canvas__table-grid"
:style="tablePickerGridStyle"
>
<button
v-for="cell in tablePickerCells"
:key="cell.key"
type="button"
class="article-editor-canvas__table-cell"
:class="{ 'article-editor-canvas__table-cell--active': cell.row <= tablePreviewRow && cell.col <= tablePreviewCol }"
@mouseenter="updateTablePreview(cell.row, cell.col)"
@focus="updateTablePreview(cell.row, cell.col)"
@pointerdown.prevent.stop="beginTableSelection(cell.row, cell.col)"
@keydown.enter.prevent="insertTableFromSelection(cell.row, cell.col)"
@keydown.space.prevent="insertTableFromSelection(cell.row, cell.col)"
></button>
</div>
<div class="article-editor-canvas__table-panel-hint">{{ t("article.editor.tablePicker.hint") }}</div>
</div>
</div>
<span class="article-editor-canvas__divider"></span>
<input
ref="imageInputRef"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="handleImageFileChange"
/>
<a-button
size="small"
type="text"
:loading="imageUploading"
:disabled="imageUploading"
@mousedown.prevent
@click="insertImage"
>
<template #icon><PictureOutlined /></template>
</a-button>
</div>
<div class="article-editor-canvas__surface">
<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>
</div>
</template>
<style scoped>
.article-editor-canvas {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid #edf2fa;
border-radius: 24px;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(64, 110, 255, 0.08), transparent 20%),
linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
}
.article-editor-canvas__toolbar {
position: relative;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid #eef2f8;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
}
.article-editor-canvas__divider {
width: 1px;
height: 20px;
margin: 0 2px;
background: #e5ebf5;
}
.article-editor-canvas__table-trigger {
position: relative;
display: flex;
align-items: center;
}
.article-editor-canvas__toolbar :deep(.article-editor-canvas__toolbar-button--active) {
color: #245bdb;
background: rgba(47, 107, 255, 0.1);
}
.article-editor-canvas__table-panel {
position: absolute;
top: calc(100% + 12px);
left: -18px;
z-index: 24;
width: max-content;
max-width: min(calc(100vw - 48px), 520px);
padding: 18px;
border: 1px solid #e3e9f4;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 255, 0.98) 100%);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.14),
0 2px 8px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(16px);
}
.article-editor-canvas__table-panel-status {
margin-bottom: 14px;
color: #101828;
font-size: 14px;
font-weight: 700;
text-align: center;
}
.article-editor-canvas__table-grid {
display: grid;
gap: 6px;
justify-content: start;
}
.article-editor-canvas__table-cell {
width: 22px;
height: 22px;
padding: 0;
border: 1px solid #d5dbe7;
border-radius: 4px;
background: #ffffff;
cursor: crosshair;
transition:
border-color 0.14s ease,
background-color 0.14s ease,
transform 0.14s ease,
box-shadow 0.14s ease;
}
.article-editor-canvas__table-cell:hover,
.article-editor-canvas__table-cell--active {
border-color: #2f6bff;
background: rgba(47, 107, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(47, 107, 255, 0.08);
}
.article-editor-canvas__table-cell:focus-visible {
outline: 2px solid rgba(47, 107, 255, 0.36);
outline-offset: 2px;
}
.article-editor-canvas__table-panel-hint {
margin-top: 12px;
color: #667085;
font-size: 12px;
line-height: 1.4;
}
.article-editor-canvas__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__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>