275 lines
7.2 KiB
Vue
275 lines
7.2 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import {
|
||
|
|
BoldOutlined,
|
||
|
|
ItalicOutlined,
|
||
|
|
OrderedListOutlined,
|
||
|
|
PicCenterOutlined,
|
||
|
|
PictureOutlined,
|
||
|
|
RedoOutlined,
|
||
|
|
UndoOutlined,
|
||
|
|
UnorderedListOutlined,
|
||
|
|
} from "@ant-design/icons-vue";
|
||
|
|
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
||
|
|
import {
|
||
|
|
insertHrCommand,
|
||
|
|
insertImageCommand,
|
||
|
|
toggleEmphasisCommand,
|
||
|
|
toggleStrongCommand,
|
||
|
|
wrapInBlockquoteCommand,
|
||
|
|
wrapInBulletListCommand,
|
||
|
|
wrapInHeadingCommand,
|
||
|
|
wrapInOrderedListCommand,
|
||
|
|
} from "@milkdown/kit/preset/commonmark";
|
||
|
|
import { callCommand } 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, ref, watch } from "vue";
|
||
|
|
import { useI18n } from "vue-i18n";
|
||
|
|
|
||
|
|
const props = defineProps<{
|
||
|
|
title: string;
|
||
|
|
modelValue: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
}>();
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
"update:title": [value: string];
|
||
|
|
"update:modelValue": [value: string];
|
||
|
|
}>();
|
||
|
|
|
||
|
|
const { t } = useI18n();
|
||
|
|
const crepeRef = ref<Crepe | null>(null);
|
||
|
|
|
||
|
|
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,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
crepe.setReadonly(!!props.disabled);
|
||
|
|
crepe.on((listener) => {
|
||
|
|
listener.markdownUpdated((_ctx, markdown) => {
|
||
|
|
emit("update:modelValue", markdown);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
crepeRef.value = crepe;
|
||
|
|
return crepe;
|
||
|
|
});
|
||
|
|
|
||
|
|
const [instanceLoading, getEditor] = useInstance();
|
||
|
|
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => props.disabled,
|
||
|
|
(value) => {
|
||
|
|
crepeRef.value?.setReadonly(!!value);
|
||
|
|
},
|
||
|
|
{ immediate: true },
|
||
|
|
);
|
||
|
|
|
||
|
|
onBeforeUnmount(() => {
|
||
|
|
crepeRef.value = null;
|
||
|
|
});
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
const src = window.prompt(t("article.editor.imagePrompt"));
|
||
|
|
if (!src?.trim()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
runCommand(insertImageCommand, { src: src.trim() });
|
||
|
|
}
|
||
|
|
</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><UndoOutlined /></template>
|
||
|
|
</a-button>
|
||
|
|
<a-button size="small" type="text" @click="runCommand(redoCommand)">
|
||
|
|
<template #icon><RedoOutlined /></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(wrapInBlockquoteCommand)">
|
||
|
|
“ ”
|
||
|
|
</a-button>
|
||
|
|
<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>
|
||
|
|
<a-button size="small" type="text" @click="runCommand(insertHrCommand)">
|
||
|
|
<template #icon><PicCenterOutlined /></template>
|
||
|
|
</a-button>
|
||
|
|
<a-button size="small" type="text" @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 {
|
||
|
|
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__surface {
|
||
|
|
position: relative;
|
||
|
|
flex: 1;
|
||
|
|
min-height: 0;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.article-editor-canvas__title-row {
|
||
|
|
position: relative;
|
||
|
|
z-index: 1;
|
||
|
|
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>
|