1045 lines
26 KiB
Vue
1045 lines
26 KiB
Vue
<template>
|
|
<div class="tiptap-editor-wrapper" :class="{ focused: isFocused }">
|
|
<div v-if="editor" class="tiptap-toolbar">
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="加粗"
|
|
:class="{ active: editor.isActive('bold') }"
|
|
@click="editor.chain().focus().toggleBold().run()"
|
|
>
|
|
<strong>B</strong>
|
|
</button>
|
|
<button
|
|
title="斜体"
|
|
:class="{ active: editor.isActive('italic') }"
|
|
@click="editor.chain().focus().toggleItalic().run()"
|
|
>
|
|
<em>I</em>
|
|
</button>
|
|
<button
|
|
title="下划线"
|
|
:class="{ active: editor.isActive('underline') }"
|
|
@click="editor.chain().focus().toggleUnderline().run()"
|
|
>
|
|
<u>U</u>
|
|
</button>
|
|
<button
|
|
title="删除线"
|
|
:class="{ active: editor.isActive('strike') }"
|
|
@click="editor.chain().focus().toggleStrike().run()"
|
|
>
|
|
<s>S</s>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<select
|
|
title="标题"
|
|
:value="currentHeadingLevel"
|
|
@change="setHeading($event.target.value)"
|
|
>
|
|
<option value="0">正文</option>
|
|
<option value="1">标题 1</option>
|
|
<option value="2">标题 2</option>
|
|
<option value="3">标题 3</option>
|
|
<option value="4">标题 4</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="有序列表"
|
|
:class="{ active: editor.isActive('orderedList') }"
|
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
|
>
|
|
1.
|
|
</button>
|
|
<button
|
|
title="无序列表"
|
|
:class="{ active: editor.isActive('bulletList') }"
|
|
@click="editor.chain().focus().toggleBulletList().run()"
|
|
>
|
|
•
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="引用"
|
|
:class="{ active: editor.isActive('blockquote') }"
|
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
|
>
|
|
“
|
|
</button>
|
|
<button
|
|
title="代码块"
|
|
:class="{ active: editor.isActive('codeBlock') }"
|
|
@click="editor.chain().focus().toggleCodeBlock().run()"
|
|
>
|
|
</>
|
|
</button>
|
|
<button
|
|
title="行内代码"
|
|
:class="{ active: editor.isActive('code') }"
|
|
@click="editor.chain().focus().toggleCode().run()"
|
|
>
|
|
Code
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="水平线"
|
|
@click="editor.chain().focus().setHorizontalRule().run()"
|
|
>
|
|
—
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="插入链接"
|
|
:class="{ active: editor.isActive('link') }"
|
|
@click="toggleLink"
|
|
>
|
|
🔗
|
|
</button>
|
|
<button title="上传图片" @click="triggerImageUpload">
|
|
📷
|
|
</button>
|
|
<input
|
|
ref="imageInputRef"
|
|
type="file"
|
|
accept="image/*"
|
|
style="display: none"
|
|
@change="handleImageUpload"
|
|
/>
|
|
<button title="插入视频" @click="insertVideo">
|
|
▶
|
|
</button>
|
|
<button title="上传附件" @click="triggerAttachmentUpload">
|
|
📎
|
|
</button>
|
|
<input
|
|
ref="attachmentInputRef"
|
|
type="file"
|
|
accept="*/*"
|
|
multiple
|
|
style="display: none"
|
|
@change="handleAttachmentUpload"
|
|
/>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="插入表格"
|
|
@click="editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()"
|
|
>
|
|
▦
|
|
</button>
|
|
<template v-if="editor.isActive('table')">
|
|
<button title="删除表格" @click="editor.chain().focus().deleteTable().run()">
|
|
✕ 表
|
|
</button>
|
|
<button title="删除行" @click="editor.chain().focus().deleteRow().run()">
|
|
- 行
|
|
</button>
|
|
<button title="删除列" @click="editor.chain().focus().deleteColumn().run()">
|
|
- 列
|
|
</button>
|
|
<button title="合并单元格" @click="editor.chain().focus().mergeCells().run()">
|
|
合并
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<div class="color-picker-wrapper" title="文字颜色">
|
|
<span class="color-label" :style="{ borderBottomColor: currentTextColor }">A</span>
|
|
<input
|
|
type="color"
|
|
:value="currentTextColor"
|
|
@input="editor.chain().focus().setColor($event.target.value).run()"
|
|
/>
|
|
</div>
|
|
<div class="color-picker-wrapper" title="背景色">
|
|
<span class="color-label highlight" :style="{ backgroundColor: currentHighlightColor }">A</span>
|
|
<input
|
|
type="color"
|
|
:value="currentHighlightColor"
|
|
@input="editor.chain().focus().toggleHighlight({ color: $event.target.value }).run()"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="左对齐"
|
|
:class="{ active: editor.isActive({ textAlign: 'left' }) }"
|
|
@click="editor.chain().focus().setTextAlign('left').run()"
|
|
>
|
|
⇤
|
|
</button>
|
|
<button
|
|
title="居中"
|
|
:class="{ active: editor.isActive({ textAlign: 'center' }) }"
|
|
@click="editor.chain().focus().setTextAlign('center').run()"
|
|
>
|
|
↔
|
|
</button>
|
|
<button
|
|
title="右对齐"
|
|
:class="{ active: editor.isActive({ textAlign: 'right' }) }"
|
|
@click="editor.chain().focus().setTextAlign('right').run()"
|
|
>
|
|
⇥
|
|
</button>
|
|
<button
|
|
title="两端对齐"
|
|
:class="{ active: editor.isActive({ textAlign: 'justify' }) }"
|
|
@click="editor.chain().focus().setTextAlign('justify').run()"
|
|
>
|
|
↧
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-divider"></div>
|
|
|
|
<div class="toolbar-group">
|
|
<button
|
|
title="撤销"
|
|
:disabled="!editor.can().undo()"
|
|
@click="editor.chain().focus().undo().run()"
|
|
>
|
|
↩
|
|
</button>
|
|
<button
|
|
title="重做"
|
|
:disabled="!editor.can().redo()"
|
|
@click="editor.chain().focus().redo().run()"
|
|
>
|
|
↪
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-container">
|
|
<editor-content :editor="editor" class="tiptap-content" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed, onBeforeUnmount } from 'vue';
|
|
import { useEditor, EditorContent } from '@tiptap/vue-3';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import { Underline } from '@tiptap/extension-underline';
|
|
import { TextStyle } from '@tiptap/extension-text-style';
|
|
import { Color } from '@tiptap/extension-color';
|
|
import { Highlight } from '@tiptap/extension-highlight';
|
|
import { Placeholder } from '@tiptap/extension-placeholder';
|
|
import { Link } from '@tiptap/extension-link';
|
|
import { Image } from '@tiptap/extension-image';
|
|
import { Table } from '@tiptap/extension-table';
|
|
import { TableRow } from '@tiptap/extension-table-row';
|
|
import { TableCell } from '@tiptap/extension-table-cell';
|
|
import { TableHeader } from '@tiptap/extension-table-header';
|
|
import { TextAlign } from '@tiptap/extension-text-align';
|
|
import { ElMessage } from 'element-plus';
|
|
import { uploadFile } from '@/api/file';
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
|
|
const isFocused = ref(false);
|
|
const imageInputRef = ref(null);
|
|
const attachmentInputRef = ref(null);
|
|
const currentTextColor = ref('#000000');
|
|
const currentHighlightColor = ref('#ffff00');
|
|
|
|
const currentHeadingLevel = computed(() => {
|
|
if (!editor.value) return '0';
|
|
for (let i = 1; i <= 6; i++) {
|
|
if (editor.value.isActive('heading', { level: i })) return String(i);
|
|
}
|
|
return '0';
|
|
});
|
|
|
|
const getUploadUrl = () => {
|
|
return import.meta.env.VITE_API_BASE_URL;
|
|
};
|
|
|
|
const getAuthHeaders = () => {
|
|
const token = localStorage.getItem('token');
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
};
|
|
|
|
const editor = useEditor({
|
|
content: props.modelValue || '',
|
|
extensions: [
|
|
StarterKit,
|
|
Underline,
|
|
TextStyle,
|
|
Color,
|
|
Highlight.configure({ multicolor: true }),
|
|
Placeholder.configure({ placeholder: '请输入内容...' }),
|
|
Link.configure({ openOnClick: false }),
|
|
Image,
|
|
Table.configure({ resizable: true }),
|
|
TableRow,
|
|
TableCell,
|
|
TableHeader,
|
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
],
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'tiptap',
|
|
},
|
|
},
|
|
onUpdate: ({ editor: e }) => {
|
|
if (!isDestroyed) {
|
|
emit('update:modelValue', e.getHTML());
|
|
}
|
|
},
|
|
onFocus: () => { isFocused.value = true; },
|
|
onBlur: () => { isFocused.value = false; },
|
|
});
|
|
|
|
let isDestroyed = false;
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(newVal) => {
|
|
if (isDestroyed || !editor.value) return;
|
|
const currentHtml = editor.value.getHTML();
|
|
if (newVal !== currentHtml) {
|
|
editor.value.commands.setContent(newVal || '', false);
|
|
}
|
|
},
|
|
);
|
|
|
|
const setHeading = (level) => {
|
|
if (!editor.value) return;
|
|
const l = parseInt(level);
|
|
if (l === 0) {
|
|
editor.value.chain().focus().setParagraph().run();
|
|
} else {
|
|
editor.value.chain().focus().toggleHeading({ level: l }).run();
|
|
}
|
|
};
|
|
|
|
const toggleLink = () => {
|
|
if (!editor.value) return;
|
|
if (editor.value.isActive('link')) {
|
|
editor.value.chain().focus().unsetLink().run();
|
|
return;
|
|
}
|
|
const url = window.prompt('请输入链接地址:', 'https://');
|
|
if (url) {
|
|
editor.value.chain().focus().setLink({ href: url }).run();
|
|
}
|
|
};
|
|
|
|
const triggerImageUpload = () => {
|
|
imageInputRef.value?.click();
|
|
};
|
|
|
|
const handleImageUpload = async (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file || !editor.value) return;
|
|
event.target.value = '';
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('cate', 'article');
|
|
|
|
const response = await uploadFile(formData);
|
|
|
|
if (response && (response.code === 200 || response.code === 201) && response.data?.url) {
|
|
const fileUrl = response.data.url;
|
|
const baseUrl = getUploadUrl() || window.location.origin;
|
|
|
|
let fullUrl = fileUrl;
|
|
if (!fileUrl.startsWith('http')) {
|
|
const base = baseUrl.replace(/\/$/, '');
|
|
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
|
|
fullUrl = `${base}${url}`;
|
|
}
|
|
|
|
editor.value.chain().focus().setImage({ src: fullUrl, alt: file.name }).run();
|
|
ElMessage.success(response.code === 201 ? '使用已有图片' : '图片上传成功');
|
|
} else {
|
|
ElMessage.error('上传失败:' + (response?.msg || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
ElMessage.error('上传失败:' + (error.message || '未知错误'));
|
|
}
|
|
};
|
|
|
|
const insertVideo = () => {
|
|
if (!editor.value) return;
|
|
const url = window.prompt('请输入视频地址:', 'https://');
|
|
if (url) {
|
|
editor.value.chain().focus().insertContent(`<video src="${url}" controls style="max-width:100%"></video>`).run();
|
|
}
|
|
};
|
|
|
|
const triggerAttachmentUpload = () => {
|
|
attachmentInputRef.value?.click();
|
|
};
|
|
|
|
const handleAttachmentUpload = async (event) => {
|
|
const files = Array.from(event.target.files || []);
|
|
if (!files.length || !editor.value) return;
|
|
event.target.value = '';
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
// 可以根据文件类型设置不同的分类
|
|
const fileType = getFileCategory(file);
|
|
formData.append('cate', fileType);
|
|
|
|
const response = await uploadFile(formData);
|
|
|
|
if (response && (response.code === 200 || response.code === 201) && response.data?.url) {
|
|
const fileUrl = response.data.url;
|
|
const baseUrl = getUploadUrl() || window.location.origin;
|
|
|
|
let fullUrl = fileUrl;
|
|
if (!fileUrl.startsWith('http')) {
|
|
const base = baseUrl.replace(/\/$/, '');
|
|
const url = fileUrl.startsWith('/') ? fileUrl : '/' + fileUrl;
|
|
fullUrl = `${base}${url}`;
|
|
}
|
|
|
|
// 插入附件链接
|
|
insertAttachmentLink(file.name, fullUrl, file.size, file.type);
|
|
|
|
ElMessage.success(response.code === 201 ? `使用已有文件: ${file.name}` : `文件上传成功: ${file.name}`);
|
|
} else {
|
|
ElMessage.error(`上传失败 ${file.name}: ` + (response?.msg || '未知错误'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Attachment upload error:', error);
|
|
ElMessage.error(`上传失败 ${file.name}: ` + (error.message || '未知错误'));
|
|
}
|
|
}
|
|
};
|
|
|
|
const getFileCategory = (file) => {
|
|
const fileType = file.type;
|
|
if (fileType.startsWith('image/')) return 'image';
|
|
if (fileType.startsWith('video/')) return 'video';
|
|
if (fileType.startsWith('audio/')) return 'audio';
|
|
if (fileType.includes('pdf')) return 'pdf';
|
|
if (fileType.includes('word') || file.name.match(/\.(doc|docx)$/i)) return 'word';
|
|
if (fileType.includes('excel') || file.name.match(/\.(xls|xlsx)$/i)) return 'excel';
|
|
if (fileType.includes('powerpoint') || file.name.match(/\.(ppt|pptx)$/i)) return 'ppt';
|
|
if (fileType.includes('zip') || fileType.includes('rar') || file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'archive';
|
|
return 'document';
|
|
};
|
|
|
|
const insertAttachmentLink = (fileName, fileUrl, fileSize, fileType) => {
|
|
if (!editor.value) return;
|
|
|
|
const sizeText = formatFileSize(fileSize);
|
|
const fileIcon = getFileIcon(fileType, fileName);
|
|
|
|
// 创建附件链接,包含图标、文件名、大小和下载链接
|
|
const attachmentHtml = `
|
|
<div class="attachment-link" style="display: flex; align-items: center; gap: 8px; margin: 8px 0; padding: 8px 12px; background: #f5f7fa; border-radius: 4px; border: 1px solid #e4e7ed;">
|
|
<span style="font-size: 20px;">${fileIcon}</span>
|
|
<div style="flex: 1; min-width: 0;">
|
|
<a href="${fileUrl}" target="_blank" rel="noopener noreferrer" style="color: #3973ff; text-decoration: none; font-weight: 500; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
|
${fileName}
|
|
</a>
|
|
<div style="font-size: 12px; color: #909399; margin-top: 2px;">
|
|
${sizeText} • ${getFileTypeText(fileType)}
|
|
</div>
|
|
</div>
|
|
<a href="${fileUrl}" download="${fileName}" style="color: #409eff; text-decoration: none; padding: 4px 8px; border: 1px solid #409eff; border-radius: 3px; font-size: 12px;">
|
|
下载
|
|
</a>
|
|
</div>
|
|
`;
|
|
|
|
editor.value.chain().focus().insertContent(attachmentHtml).run();
|
|
};
|
|
|
|
const formatFileSize = (bytes) => {
|
|
if (bytes === 0 || !bytes) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getFileIcon = (fileType, fileName) => {
|
|
// 根据文件类型返回相应的图标
|
|
if (fileType.startsWith('image/')) return '🖼️';
|
|
if (fileType.startsWith('video/')) return '🎬';
|
|
if (fileType.startsWith('audio/')) return '🎵';
|
|
if (fileType.includes('pdf') || fileName.match(/\.pdf$/i)) return '📄';
|
|
if (fileType.includes('word') || fileName.match(/\.(doc|docx)$/i)) return '📝';
|
|
if (fileType.includes('excel') || fileName.match(/\.(xls|xlsx)$/i)) return '📊';
|
|
if (fileType.includes('powerpoint') || fileName.match(/\.(ppt|pptx)$/i)) return '📽️';
|
|
if (fileType.includes('zip') || fileType.includes('rar') || fileName.match(/\.(zip|rar|7z|tar|gz)$/i)) return '📦';
|
|
if (fileName.match(/\.(txt|md|json|xml|html|js|ts|css)$/i)) return '📄';
|
|
return '📎';
|
|
};
|
|
|
|
const getFileTypeText = (fileType) => {
|
|
if (fileType.startsWith('image/')) return '图片';
|
|
if (fileType.startsWith('video/')) return '视频';
|
|
if (fileType.startsWith('audio/')) return '音频';
|
|
if (fileType.includes('pdf')) return 'PDF文档';
|
|
if (fileType.includes('word')) return 'Word文档';
|
|
if (fileType.includes('excel')) return 'Excel表格';
|
|
if (fileType.includes('powerpoint')) return 'PPT演示';
|
|
if (fileType.includes('zip') || fileType.includes('rar')) return '压缩文件';
|
|
if (fileType.includes('text')) return '文本文件';
|
|
return '文档文件';
|
|
};
|
|
|
|
defineExpose({
|
|
clear: () => {
|
|
if (isDestroyed || !editor.value) return;
|
|
editor.value.commands.clearContent();
|
|
},
|
|
getContent: () => {
|
|
if (isDestroyed || !editor.value) return props.modelValue;
|
|
return editor.value.getHTML();
|
|
},
|
|
setContent: (content) => {
|
|
if (isDestroyed || !editor.value) return;
|
|
editor.value.commands.setContent(content || '', false);
|
|
},
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
isDestroyed = true;
|
|
editor.value?.destroy();
|
|
});
|
|
</script>
|
|
|
|
<style lang="less">
|
|
.tiptap-editor-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 650px;
|
|
border: 1px solid #dcdfe6;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background: #ffffff;
|
|
|
|
&.focused {
|
|
border-color: #409eff;
|
|
}
|
|
|
|
.tiptap-toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 6px 8px;
|
|
background: #f5f7fa;
|
|
border-bottom: 1px solid #dcdfe6;
|
|
flex-shrink: 0;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
|
|
button, select {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 28px;
|
|
height: 28px;
|
|
padding: 2px 6px;
|
|
border: 1px solid transparent;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
color: #606266;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
line-height: 1;
|
|
|
|
&:hover {
|
|
background: #e8eaed;
|
|
color: #303133;
|
|
}
|
|
|
|
&.active {
|
|
background: #ecf5ff;
|
|
color: #409eff;
|
|
border-color: #b3d8ff;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
select {
|
|
min-width: 70px;
|
|
padding: 2px 4px;
|
|
border-color: #dcdfe6;
|
|
background: #fff;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.color-picker-wrapper {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 28px;
|
|
height: 28px;
|
|
cursor: pointer;
|
|
|
|
.color-label {
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
border-bottom: 3px solid #000;
|
|
line-height: 1;
|
|
padding-bottom: 1px;
|
|
|
|
&.highlight {
|
|
border-bottom-color: transparent;
|
|
background-color: #ffff00;
|
|
padding: 0 2px;
|
|
}
|
|
}
|
|
|
|
input[type="color"] {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
}
|
|
|
|
.toolbar-divider {
|
|
width: 1px;
|
|
height: 20px;
|
|
background: #dcdfe6;
|
|
margin: 0 4px;
|
|
}
|
|
}
|
|
|
|
.editor-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
padding: 0;
|
|
|
|
&::-webkit-scrollbar {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background: #c0c4cc;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-track {
|
|
background: #f5f7fa;
|
|
border-radius: 3px;
|
|
}
|
|
}
|
|
|
|
.tiptap-content {
|
|
height: 100%;
|
|
}
|
|
|
|
.tiptap {
|
|
outline: none;
|
|
min-height: 100%;
|
|
padding: 16px;
|
|
|
|
> * + * {
|
|
margin-top: 0.75em;
|
|
}
|
|
|
|
p {
|
|
color: #1a1a2e;
|
|
margin: 8px 0;
|
|
line-height: 1.8;
|
|
}
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
color: #1a1a2e;
|
|
font-weight: 600;
|
|
margin: 16px 0 8px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
h1 { font-size: 2em; }
|
|
h2 { font-size: 1.5em; }
|
|
h3 { font-size: 1.25em; }
|
|
h4 { font-size: 1.1em; }
|
|
|
|
a {
|
|
color: #3973ff;
|
|
text-decoration: underline;
|
|
cursor: pointer;
|
|
}
|
|
|
|
code {
|
|
background: #f5f7fa;
|
|
color: #1a1a2e;
|
|
border: 1px solid #e4e7ed;
|
|
border-radius: 3px;
|
|
padding: 2px 6px;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
font-size: 13px;
|
|
}
|
|
|
|
pre {
|
|
background: #f5f7fa;
|
|
border: 1px solid #e4e7ed;
|
|
border-radius: 4px;
|
|
padding: 12px 16px;
|
|
margin: 12px 0;
|
|
overflow-x: auto;
|
|
|
|
code {
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: #1a1a2e;
|
|
}
|
|
}
|
|
|
|
blockquote {
|
|
border-left: 4px solid #3973ff;
|
|
background: #f5f7fa;
|
|
color: #606266;
|
|
padding: 8px 16px;
|
|
margin: 12px 0;
|
|
|
|
> * + * {
|
|
margin-top: 0.5em;
|
|
}
|
|
}
|
|
|
|
table {
|
|
border-collapse: collapse;
|
|
border: 1px solid #e4e7ed;
|
|
width: 100%;
|
|
margin: 12px 0;
|
|
table-layout: fixed;
|
|
|
|
th, td {
|
|
border: 1px solid #e4e7ed;
|
|
padding: 8px 12px;
|
|
min-width: 60px;
|
|
position: relative;
|
|
vertical-align: top;
|
|
}
|
|
|
|
th {
|
|
background: #f5f7fa;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.selectedCell::after {
|
|
z-index: 2;
|
|
position: absolute;
|
|
content: "";
|
|
left: 0;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
background: rgba(200, 200, 255, 0.3);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.column-resize-handle {
|
|
position: absolute;
|
|
right: -2px;
|
|
top: 0;
|
|
bottom: -2px;
|
|
width: 4px;
|
|
background: #adf;
|
|
pointer-events: none;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
ul, ol {
|
|
color: #1a1a2e;
|
|
padding-left: 24px;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
li {
|
|
color: #1a1a2e;
|
|
line-height: 1.8;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
hr {
|
|
border: none;
|
|
border-top: 1px solid #e4e7ed;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
img {
|
|
max-width: 100%;
|
|
border-radius: 4px;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
video {
|
|
max-width: 100%;
|
|
border-radius: 4px;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
mark {
|
|
background-color: #fff3cd;
|
|
padding: 0 2px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
p.is-editor-empty:first-child::before {
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
color: #adb5bd;
|
|
pointer-events: none;
|
|
height: 0;
|
|
}
|
|
|
|
.attachment-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 8px 0;
|
|
padding: 8px 12px;
|
|
background: #f5f7fa;
|
|
border-radius: 4px;
|
|
border: 1px solid #e4e7ed;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
background: #ecf5ff;
|
|
border-color: #b3d8ff;
|
|
}
|
|
|
|
a[href] {
|
|
color: #3973ff;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
|
|
&:hover {
|
|
color: #1d5fdd;
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
.file-info {
|
|
font-size: 12px;
|
|
color: #909399;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.download-btn {
|
|
color: #409eff;
|
|
text-decoration: none;
|
|
padding: 4px 8px;
|
|
border: 1px solid #409eff;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
background: #409eff;
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
html.dark {
|
|
.tiptap-editor-wrapper {
|
|
border-color: #3d3d3d;
|
|
background: #1a1a1a;
|
|
|
|
.tiptap-toolbar {
|
|
background: #2d2d2d;
|
|
border-color: #3d3d3d;
|
|
|
|
.toolbar-group button, .toolbar-group select {
|
|
color: #b0b0b0;
|
|
|
|
&:hover {
|
|
background: #3d3d3d;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
&.active {
|
|
background: #1a3a5c;
|
|
color: #409eff;
|
|
border-color: #2a5a8c;
|
|
}
|
|
}
|
|
|
|
.toolbar-divider {
|
|
background: #3d3d3d;
|
|
}
|
|
}
|
|
|
|
.editor-container {
|
|
&::-webkit-scrollbar-thumb {
|
|
background: #4d4d4d;
|
|
}
|
|
|
|
&::-webkit-scrollbar-track {
|
|
background: #2d2d2d;
|
|
}
|
|
}
|
|
|
|
.tiptap {
|
|
p, li, h1, h2, h3, h4, h5, h6 {
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
a {
|
|
color: #4f84ff;
|
|
}
|
|
|
|
code {
|
|
background: #2d2d2d;
|
|
color: #e0e0e0;
|
|
border-color: #3d3d3d;
|
|
}
|
|
|
|
pre {
|
|
background: #2d2d2d;
|
|
border-color: #3d3d3d;
|
|
color: #e0e0e0;
|
|
|
|
code {
|
|
background: transparent;
|
|
border: none;
|
|
color: #e0e0e0;
|
|
}
|
|
}
|
|
|
|
blockquote {
|
|
border-color: #4f84ff;
|
|
background: #2d2d2d;
|
|
color: #b0b0b0;
|
|
}
|
|
|
|
table {
|
|
border-color: #3d3d3d;
|
|
|
|
th, td {
|
|
border-color: #3d3d3d;
|
|
background: #1a1a1a;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
th {
|
|
background: #2d2d2d;
|
|
}
|
|
}
|
|
|
|
ul, ol {
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
hr {
|
|
border-color: #3d3d3d;
|
|
}
|
|
|
|
mark {
|
|
background-color: #4a3d00;
|
|
color: #ffd666;
|
|
}
|
|
|
|
.attachment-link {
|
|
background: #2d2d2d;
|
|
border-color: #3d3d3d;
|
|
|
|
&:hover {
|
|
background: #1a3a5c;
|
|
border-color: #2a5a8c;
|
|
}
|
|
|
|
a[href] {
|
|
color: #4f84ff;
|
|
|
|
&:hover {
|
|
color: #2d68ff;
|
|
}
|
|
}
|
|
|
|
.file-info {
|
|
color: #b0b0b0;
|
|
}
|
|
|
|
.download-btn {
|
|
color: #409eff;
|
|
border-color: #409eff;
|
|
|
|
&:hover {
|
|
background: #409eff;
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|