This commit is contained in:
parent
f55a608034
commit
1c9f69a53b
|
|
@ -32,7 +32,7 @@
|
||||||
<div id="app" class="pi">
|
<div id="app" class="pi">
|
||||||
<div class="app-loading">
|
<div class="app-loading">
|
||||||
<div class="app-loading__logo">
|
<div class="app-loading__logo">
|
||||||
<img src="images/logo.png"/>
|
<img src="/images/logo.png"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-loading__loader"></div>
|
<div class="app-loading__loader"></div>
|
||||||
<div class="app-loading__title">%VITE_APP_TITLE%</div>
|
<div class="app-loading__title">%VITE_APP_TITLE%</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "1.12.0",
|
"axios": "1.12.0",
|
||||||
"cron-parser": "^4.9",
|
"cron-parser": "^4.9",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import {TextStyle} from '@tiptap/extension-text-style'
|
|
||||||
|
|
||||||
export default TextStyle.extend({
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
fontSize: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: element => element.style.fontSize || null,
|
|
||||||
renderHTML: attributes => {
|
|
||||||
if (!attributes.fontSize) return {}
|
|
||||||
return {
|
|
||||||
style: `font-size: ${attributes.fontSize}`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
<template>
|
|
||||||
<NodeViewWrapper as="p" class="image-wrapper">
|
|
||||||
<div class="image-box" ref="boxRef" @click="handleClick">
|
|
||||||
<img :src="node.attrs.src" :alt="node.attrs.alt" :width="node.attrs.width" :height="node.attrs.height" :draggable="false"/>
|
|
||||||
<!-- 操作菜单,仅在选中时显示 -->
|
|
||||||
<div v-if="selected" class="image-menu">
|
|
||||||
<button @click="deleteImage">删除</button>
|
|
||||||
<button @click="replaceImage">配置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-dialog v-model="settingShow" title="配置" width="500" align-center>
|
|
||||||
<el-form v-model="form" label-position="top">
|
|
||||||
<el-form-item label="地址" prop="src">
|
|
||||||
<el-input v-model="form.src"></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="描述" prop="alt">
|
|
||||||
<el-input v-model="form.alt"></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="宽高" prop="width">
|
|
||||||
<el-row :gutter="10" style="margin: 10px 0; width: 100%;">
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="form.width">
|
|
||||||
<template #suffix>宽 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="form.height">
|
|
||||||
<template #suffix>高 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="settingShow = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="submit">确定</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {NodeViewWrapper, NodeViewProps} from '@tiptap/vue-3'
|
|
||||||
import {computed, ref} from "vue"
|
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>();
|
|
||||||
const selected = computed(() => props.selected)
|
|
||||||
|
|
||||||
let settingShow = ref(false)
|
|
||||||
let form = ref({
|
|
||||||
src: '',
|
|
||||||
width: '',
|
|
||||||
height: '',
|
|
||||||
alt: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function deleteImage() {
|
|
||||||
props.deleteNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceImage() {
|
|
||||||
form.value.src = props.node.attrs.src
|
|
||||||
form.value.width = props.node.attrs.width
|
|
||||||
form.value.height = props.node.attrs.height
|
|
||||||
form.value.alt = props.node.attrs.alt
|
|
||||||
settingShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
props.editor.commands.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
settingShow.value = false
|
|
||||||
props.updateAttributes(form.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-box img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 操作菜单 */
|
|
||||||
.image-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-menu button {
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
border: none;
|
|
||||||
padding: 4px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import Action from "./Action.vue"
|
|
||||||
import {VueNodeViewRenderer} from "@tiptap/vue-3"
|
|
||||||
import { Image } from '@tiptap/extension-image'
|
|
||||||
|
|
||||||
export default Image.extend({
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
class: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
alt: {
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addNodeView() {
|
|
||||||
return VueNodeViewRenderer(Action)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import {Extension, Command} from '@tiptap/core'
|
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
indent: {
|
|
||||||
increaseIndent: () => ReturnType
|
|
||||||
decreaseIndent: () => ReturnType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Extension.create({
|
|
||||||
name: 'indent',
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
maxIndent: 50,
|
|
||||||
types: ['paragraph', 'heading', 'code_block', 'bullet_list', 'ordered_list'],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addGlobalAttributes() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
types: this.options.types,
|
|
||||||
attributes: {
|
|
||||||
'indent': {
|
|
||||||
default: 0,
|
|
||||||
parseHTML: element => parseInt(element.getAttribute('indent') || '0', 10),
|
|
||||||
renderHTML: attributes => {
|
|
||||||
const level = attributes['indent']
|
|
||||||
return level > 0 && {
|
|
||||||
'indent': level,
|
|
||||||
style: `text-indent: ${level * 2}em;`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
increaseIndent:
|
|
||||||
(): Command =>
|
|
||||||
({tr, state, dispatch}) => {
|
|
||||||
const {from, to} = state.selection
|
|
||||||
let modified = false
|
|
||||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
||||||
if (this.options.types.includes(node.type.name)) {
|
|
||||||
const indent = node.attrs['indent'] || 0
|
|
||||||
if (indent + 1 > this.options.maxIndent) return
|
|
||||||
tr.setNodeMarkup(pos, undefined, {
|
|
||||||
...node.attrs,
|
|
||||||
'indent': indent + 1,
|
|
||||||
})
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (modified) {
|
|
||||||
dispatch && dispatch(tr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
|
|
||||||
decreaseIndent:
|
|
||||||
(): Command =>
|
|
||||||
({tr, state, dispatch}) => {
|
|
||||||
const {from, to} = state.selection
|
|
||||||
let modified = false
|
|
||||||
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
||||||
if (this.options.types.includes(node.type.name)) {
|
|
||||||
const indent = node.attrs['indent'] || 0
|
|
||||||
if (indent - 1 < 0) return
|
|
||||||
const newIndent = Math.max(indent - 1, 0)
|
|
||||||
tr.setNodeMarkup(pos, undefined, {
|
|
||||||
...node.attrs,
|
|
||||||
'indent': newIndent,
|
|
||||||
})
|
|
||||||
modified = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (modified) {
|
|
||||||
dispatch && dispatch(tr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
Tab: () => this.editor.commands.increaseIndent(),
|
|
||||||
'Shift-Tab': () => this.editor.commands.decreaseIndent(),
|
|
||||||
Backspace: () => {
|
|
||||||
const {state} = this.editor
|
|
||||||
const {selection} = state
|
|
||||||
const {$from} = selection
|
|
||||||
if ($from.parentOffset !== 0) return false
|
|
||||||
const node = $from.node()
|
|
||||||
if (!this.options.types.includes(node.type.name)) return false
|
|
||||||
const indent = node.attrs['indent'] || 0
|
|
||||||
if (indent > 0) {
|
|
||||||
return this.editor.commands.decreaseIndent()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import {Extension} from '@tiptap/core'
|
|
||||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
|
||||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
selection: {
|
|
||||||
setSelection: () => ReturnType
|
|
||||||
clearSelection: () => ReturnType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Extension.create({
|
|
||||||
name: 'selection',
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
class: 'selection',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addStorage() {
|
|
||||||
return {
|
|
||||||
fakeRange: null as { from: number, to: number } | null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
new Plugin({
|
|
||||||
key: new PluginKey('selection'),
|
|
||||||
props: {
|
|
||||||
decorations: (state) => {
|
|
||||||
const {fakeRange} = this.storage
|
|
||||||
if (!fakeRange) return null
|
|
||||||
const deco = Decoration.inline(
|
|
||||||
fakeRange.from,
|
|
||||||
fakeRange.to,
|
|
||||||
{class: this.options.class},
|
|
||||||
)
|
|
||||||
return DecorationSet.create(state.doc, [deco])
|
|
||||||
},
|
|
||||||
handleClick: (view, pos, event) => {
|
|
||||||
const { fakeRange } = this.storage
|
|
||||||
if (!fakeRange) return false
|
|
||||||
|
|
||||||
const clickedInsideFake =
|
|
||||||
pos >= fakeRange.from && pos <= fakeRange.to
|
|
||||||
|
|
||||||
if (!clickedInsideFake) {
|
|
||||||
// 点击了 fake selection 外部,清除它
|
|
||||||
this.storage.fakeRange = null
|
|
||||||
view.dispatch(view.state.tr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setSelection: () => ({state, view}) => {
|
|
||||||
const {from, to, empty} = state.selection
|
|
||||||
if (!empty && from !== to) {
|
|
||||||
this.storage.fakeRange = {from, to}
|
|
||||||
view.dispatch(state.tr)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
clearSelection: () => ({state, view}) => {
|
|
||||||
if (this.storage.fakeRange) {
|
|
||||||
this.storage.fakeRange = null
|
|
||||||
view.dispatch(state.tr)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<template>
|
|
||||||
<NodeViewWrapper class="table-wrapper" @contextmenu.prevent="showMenu($event)">
|
|
||||||
<NodeViewContent as="table" v-bind="props.node.attrs" :class="isSelected ? 'ProseMirror-selectednode' : ''"/>
|
|
||||||
<el-dropdown ref="tableDropdownRef" trigger="contextmenu" :teleported="false">
|
|
||||||
<span></span>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu ref="tableMenu" class="dropdown">
|
|
||||||
<el-dropdown-item @click="editor.commands.mergeCells()" :disabled="!editor.can().mergeCells()">
|
|
||||||
合并单元格
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.addRowBefore()" :disabled="!editor.can().addRowBefore()" divided>
|
|
||||||
上加一行
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.addRowAfter()" :disabled="!editor.can().addRowAfter()">
|
|
||||||
下加一行
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.addColumnBefore()"
|
|
||||||
:disabled="!editor.can().addColumnBefore()">左加一列
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.addColumnAfter()"
|
|
||||||
:disabled="!editor.can().addColumnAfter()">右加一列
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.deleteRow()" :disabled="!editor.can().deleteRow()" divided>删除行
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item @click="editor.commands.deleteColumn()" :disabled="!editor.can().deleteColumn()">
|
|
||||||
删除列
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {NodeViewWrapper, NodeViewContent, NodeViewProps} from '@tiptap/vue-3'
|
|
||||||
import {ref, computed} from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>()
|
|
||||||
import {NodeSelection} from 'prosemirror-state'
|
|
||||||
|
|
||||||
const tableDropdownRef = ref(null)
|
|
||||||
const tableMenu = ref(null)
|
|
||||||
|
|
||||||
const isSelected = computed(() => {
|
|
||||||
const {selection} = props.editor.state
|
|
||||||
|
|
||||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const $from = selection.$from
|
|
||||||
let inTable = false
|
|
||||||
for (let depth = $from.depth; depth > 0; depth--) {
|
|
||||||
const node = $from.node(depth)
|
|
||||||
if (node.type.name === 'table') {
|
|
||||||
inTable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return inTable
|
|
||||||
})
|
|
||||||
|
|
||||||
function showMenu(e) {
|
|
||||||
const _menu = tableDropdownRef.value.$el
|
|
||||||
_menu.style.position = 'absolute';
|
|
||||||
// 点位置+边框
|
|
||||||
_menu.style.left = e.layerX + 'px'
|
|
||||||
// 组件高度 + 工具栏高度 + 偏移
|
|
||||||
_menu.style.top = e.layerY + 'px'
|
|
||||||
tableDropdownRef.value.handleOpen()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.table-wrapper {
|
|
||||||
position: relative;
|
|
||||||
:deep(.el-dropdown-menu) {
|
|
||||||
list-style-type: disc;
|
|
||||||
padding-left: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-popper) {
|
|
||||||
line-height: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import {VueNodeViewRenderer} from "@tiptap/vue-3";
|
|
||||||
import {NodeSelection} from 'prosemirror-state'
|
|
||||||
import {Table} from '@tiptap/extension-table'
|
|
||||||
import Action from "./Action.vue"
|
|
||||||
|
|
||||||
export default Table.extend({
|
|
||||||
selectable: true,
|
|
||||||
atom: true,
|
|
||||||
// 添加属性配置
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
width: {
|
|
||||||
default: '100%',
|
|
||||||
renderHTML: attributes => {
|
|
||||||
return {
|
|
||||||
width: attributes.width,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
default: null,
|
|
||||||
renderHTML: attributes => {
|
|
||||||
return {
|
|
||||||
class: attributes.class,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
default: 'table-layout: fixed;border-collapse: collapse;',
|
|
||||||
renderHTML: attributes => {
|
|
||||||
if (!attributes.style) return {}
|
|
||||||
return {
|
|
||||||
style: attributes.style,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderHTML({node, HTMLAttributes}) {
|
|
||||||
return ['table', HTMLAttributes, ['tbody', 0]]
|
|
||||||
},
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
Backspace: ({editor}) => {
|
|
||||||
const {state, view} = editor
|
|
||||||
const {selection} = state
|
|
||||||
|
|
||||||
// 没有深度直接删除
|
|
||||||
const {$from} = selection
|
|
||||||
if ($from.depth == 0) {
|
|
||||||
editor.commands.deleteSelection()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选中整个表格删除
|
|
||||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
|
||||||
const tr = state.tr.delete(selection.from, selection.to)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const pos = $from.before($from.depth) // 当前段落的开始位置
|
|
||||||
const index = $from.index($from.depth - 1)
|
|
||||||
|
|
||||||
if (index == 0) return false
|
|
||||||
const parent = $from.node($from.depth - 1)
|
|
||||||
|
|
||||||
// 查前一个节点是不是 table
|
|
||||||
const beforeNode = parent.child(index - 1)
|
|
||||||
|
|
||||||
if (beforeNode?.type.name === 'table') {
|
|
||||||
const deletePos = pos - beforeNode.nodeSize
|
|
||||||
view.dispatch(
|
|
||||||
state.tr
|
|
||||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
|
||||||
.deleteSelection()
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
Delete: ({editor}) => {
|
|
||||||
const {state, view} = editor
|
|
||||||
const {selection} = state
|
|
||||||
|
|
||||||
// 没有深度直接删除
|
|
||||||
const {$from} = selection
|
|
||||||
if ($from.depth == 0) {
|
|
||||||
editor.commands.deleteSelection()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
//选中整个表格删除
|
|
||||||
if (selection instanceof NodeSelection && selection.node.type.name === 'table') {
|
|
||||||
const tr = state.tr.delete(selection.from, selection.to)
|
|
||||||
view.dispatch(tr)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在表格前删除
|
|
||||||
const pos = $from.before($from.depth) // 当前段落的开始位置
|
|
||||||
const index = $from.index($from.depth - 1)
|
|
||||||
const parent = $from.node($from.depth - 1)
|
|
||||||
|
|
||||||
if (index < parent.childCount - 1) {
|
|
||||||
const afterNode = parent.child(index + 1)
|
|
||||||
if (afterNode?.type.name === 'table') {
|
|
||||||
const deletePos = pos + $from.node().nodeSize
|
|
||||||
view.dispatch(
|
|
||||||
state.tr
|
|
||||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
|
||||||
.deleteSelection()
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (index == 0) return false
|
|
||||||
const beforeNode = parent.child(index - 1)
|
|
||||||
if (beforeNode?.type.name === 'table') {
|
|
||||||
const deletePos = pos - beforeNode.nodeSize
|
|
||||||
view.dispatch(
|
|
||||||
state.tr
|
|
||||||
.setSelection(NodeSelection.create(state.doc, deletePos))
|
|
||||||
.deleteSelection()
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addNodeView() {
|
|
||||||
return VueNodeViewRenderer(Action)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
<template>
|
|
||||||
<NodeViewWrapper as="p" class="video-wrapper">
|
|
||||||
<div class="video-box" ref="boxRef" @click="handleClick">
|
|
||||||
<NodeViewContent as="video" v-bind="props.node.attrs" controls/>
|
|
||||||
<div v-if="selected" class="video-menu">
|
|
||||||
<button @click="deleteVideo">删除</button>
|
|
||||||
<button @click="replaceVideo">配置</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-dialog v-model="settingShow" title="配置" width="500" align-center>
|
|
||||||
<el-form v-model="form" label-position="top">
|
|
||||||
<el-form-item label="地址" prop="src">
|
|
||||||
<el-input v-model="form.src"></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="宽高" prop="width">
|
|
||||||
<el-row :gutter="10" style="margin: 10px 0; width: 100%;">
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="form.width">
|
|
||||||
<template #suffix>宽 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="form.height">
|
|
||||||
<template #suffix>高 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="settingShow = false">取消</el-button>
|
|
||||||
<el-button type="primary" @click="submit">确定</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {NodeViewWrapper, NodeViewProps, NodeViewContent} from '@tiptap/vue-3'
|
|
||||||
import {computed, ref} from "vue"
|
|
||||||
|
|
||||||
const props = defineProps<NodeViewProps>();
|
|
||||||
const selected = computed(() => props.selected)
|
|
||||||
|
|
||||||
let settingShow = ref(false)
|
|
||||||
let form = ref({
|
|
||||||
src: '',
|
|
||||||
width: '',
|
|
||||||
height: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function deleteVideo() {
|
|
||||||
props.deleteNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceVideo() {
|
|
||||||
form.value.src = props.node.attrs.src
|
|
||||||
form.value.width = props.node.attrs.width
|
|
||||||
form.value.height = props.node.attrs.height
|
|
||||||
settingShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
props.editor.commands.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
settingShow.value = false
|
|
||||||
props.updateAttributes(form.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.video-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-box video {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 操作菜单 */
|
|
||||||
.video-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-menu button {
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
border: none;
|
|
||||||
padding: 4px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// extensions/Video.ts
|
|
||||||
import {Node, mergeAttributes, Command, CommandProps} from '@tiptap/core'
|
|
||||||
import {VueNodeViewRenderer} from "@tiptap/vue-3";
|
|
||||||
import Action from "./Action.vue";
|
|
||||||
|
|
||||||
export interface VideoOptions {
|
|
||||||
HTMLAttributes: Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
video: {
|
|
||||||
setVideo: (options: { src: string }) => ReturnType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Node.create<VideoOptions>({
|
|
||||||
name: 'video',
|
|
||||||
group: 'block',
|
|
||||||
atom: true,
|
|
||||||
selectable: true,
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
HTMLAttributes: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
src: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
default: '100%',
|
|
||||||
renderHTML: attributes => {
|
|
||||||
return {
|
|
||||||
width: attributes.width,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
default: null,
|
|
||||||
renderHTML: attributes => {
|
|
||||||
return {
|
|
||||||
class: attributes.class,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
default: '',
|
|
||||||
renderHTML: attributes => {
|
|
||||||
if (!attributes.style) return {}
|
|
||||||
return {
|
|
||||||
style: attributes.style,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: 'video',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
renderHTML({HTMLAttributes}) {
|
|
||||||
return [
|
|
||||||
'video',
|
|
||||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
||||||
controls: true,
|
|
||||||
}),
|
|
||||||
['source', {src: HTMLAttributes.src, type: 'video/mp4'}],
|
|
||||||
]
|
|
||||||
},
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setVideo: (attrs: { src: string }): Command => ({commands}: CommandProps) => {
|
|
||||||
return commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addNodeView() {
|
|
||||||
return VueNodeViewRenderer(Action)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,245 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="pi-editor">
|
<div class="pi-editor">
|
||||||
<div class="toolbar" ref="toolbarRef">
|
<Toolbar :editor="editorRef" :defaultConfig="toolbarConfig"/>
|
||||||
<div class="box">
|
<Editor v-model="html" :defaultConfig="editorConfig" @onCreated="editorRef = $event"/>
|
||||||
<div class="group">
|
|
||||||
<el-button text circle title="撤销" class="item"
|
|
||||||
@click="editor.chain().focus().undo().run()"
|
|
||||||
:disabled="!editor.can().undo()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-arrow-go-back'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="重做" class="item"
|
|
||||||
@click="editor.chain().focus().redo().run()"
|
|
||||||
:disabled="!editor.can().redo()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-arrow-go-forward'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-color-picker v-model="fontColor" ref="colorPickerRef"
|
|
||||||
@change="editor.chain().focus().setColor(fontColor).run()"
|
|
||||||
:predefine="predefineColors" :teleported="false" :show-alpha="true"
|
|
||||||
class="color-picker"></el-color-picker>
|
|
||||||
<el-color-picker v-model="bgColor" ref="bgPickerRef"
|
|
||||||
@change="bgColor ? editor.chain().focus().toggleHighlight({ color: bgColor }).run() : editor.chain().focus().unsetHighlight().run()"
|
|
||||||
:predefine="predefineColors" :teleported="false" :show-alpha="true"
|
|
||||||
class="color-picker"></el-color-picker>
|
|
||||||
<el-button text circle title="文字颜色" class="item"
|
|
||||||
@click="colorPickerRef.show()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-font-color'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="背景色" class="item"
|
|
||||||
@click="bgPickerRef.show()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-background-color'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="粗体" class="item"
|
|
||||||
@click="editor.chain().focus().toggleBold().run()"
|
|
||||||
:class="{ 'active': editor.isActive('bold') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-bold'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="斜体" class="item"
|
|
||||||
@click="editor.chain().focus().toggleItalic().run()"
|
|
||||||
:class="{ 'active': editor.isActive('italic') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-italic'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="下划线" class="item"
|
|
||||||
@click="editor.chain().focus().toggleUnderline().run()"
|
|
||||||
:class="{ 'active': editor.isActive('underline') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-underline'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="删除线" class="item"
|
|
||||||
@click="editor.chain().focus().toggleStrike().run()"
|
|
||||||
:class="{ 'active': editor.isActive('strike') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-strikethrough'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-button text circle title="左对齐" class="item"
|
|
||||||
:class="{ 'active': editor.isActive({ textAlign: 'left' }) }"
|
|
||||||
@click="editor.isActive({ textAlign: 'left' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('left').run()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-align-left'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="居中对齐" class="item"
|
|
||||||
:class="{ 'active': editor.isActive({ textAlign: 'center' }) }"
|
|
||||||
@click="editor.isActive({ textAlign: 'center' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('center').run()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-align-center'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="右对齐" class="item"
|
|
||||||
:class="{ 'active': editor.isActive({ textAlign: 'right' }) }"
|
|
||||||
@click="editor.isActive({ textAlign: 'right' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('right').run()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-align-right'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="两端对齐" class="item"
|
|
||||||
:class="{ 'active': editor.isActive({ textAlign: 'justify' }) }"
|
|
||||||
@click="editor.isActive({ textAlign: 'justify' }) ? editor.chain().focus().unsetTextAlign().run() : editor.chain().focus().setTextAlign('justify').run()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-align-justify'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-button text circle title="减少缩进" class="item"
|
|
||||||
@click="editor.chain().focus().decreaseIndent().run()"
|
|
||||||
:disabled="editor.can().decreaseIndent()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-indent-decrease'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="增加缩进" class="item"
|
|
||||||
@click="editor.chain().focus().increaseIndent().run()"
|
|
||||||
:disabled="editor.can().increaseIndent()">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-indent-increase'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-button text circle title="有序列表" class="item"
|
|
||||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
|
||||||
:class="{ 'active': editor.isActive('orderedList') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-list-ordered'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="无序列表" class="item"
|
|
||||||
@click="editor.chain().focus().toggleBulletList().run()"
|
|
||||||
:class="{ 'active': editor.isActive('bulletList') }">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-list-unordered'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-popover :visible="tableVisible" :width="220">
|
|
||||||
<el-row style="margin: 10px 0;" :gutter="10">
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="tableRow">
|
|
||||||
<template #suffix>行 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="12">
|
|
||||||
<el-input v-model="tableCol">
|
|
||||||
<template #suffix>列 </template>
|
|
||||||
</el-input>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
<div style="text-align: right; margin: 0">
|
|
||||||
<el-button size="small" text @click="tableVisible = false">取消</el-button>
|
|
||||||
<el-button size="small" type="primary" @click="insertTable">
|
|
||||||
确定
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<template #reference>
|
|
||||||
<el-button text circle title="表格" class="item"
|
|
||||||
@click="tableVisible = true">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-table'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-popover>
|
|
||||||
<el-button text circle title="图片" class="item"
|
|
||||||
@click="showPicker1">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-image'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button text circle title="视频" class="item"
|
|
||||||
@click="showPicker2">
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-video'"/>
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div class="group">
|
|
||||||
<el-select v-model="tag" class="item" style="width:130px" clearable @change="tagChange">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-heading'"/>
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
<el-option label="段落" value="p">
|
|
||||||
<template #default>
|
|
||||||
<p>段落</p>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H1" value="h1">
|
|
||||||
<template #default>
|
|
||||||
<h1>H1</h1>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H2" value="h2">
|
|
||||||
<template #default>
|
|
||||||
<h2>H2</h2>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H3" value="h3">
|
|
||||||
<template #default>
|
|
||||||
<h3>H3</h3>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H4" value="h4">
|
|
||||||
<template #default>
|
|
||||||
<h4>H4</h4>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H5" value="h5">
|
|
||||||
<template #default>
|
|
||||||
<h5>H5</h5>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="H6" value="h6">
|
|
||||||
<template #default>
|
|
||||||
<h6>H6</h6>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
<el-option label="预格式化" value="pre">
|
|
||||||
<template #default>
|
|
||||||
<pre>预格式化</pre>
|
|
||||||
</template>
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
<el-select v-model="fontSize" class="item" style="width:130px" clearable allow-create filterable
|
|
||||||
@change="editor.chain().focus().setMark('textStyle', { fontSize: fontSize }).run()">
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon size="20px">
|
|
||||||
<component :is="'pi-icon-font-size'"/>
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
<el-option label="12px" value="12px"/>
|
|
||||||
<el-option label="14px" value="14px"/>
|
|
||||||
<el-option label="16px" value="16px"/>
|
|
||||||
<el-option label="18px" value="18px"/>
|
|
||||||
<el-option label="22px" value="22px"/>
|
|
||||||
<el-option label="24px" value="24px"/>
|
|
||||||
<el-option label="36px" value="36px"/>
|
|
||||||
<el-option label="72px" value="72px"/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<editor-content class="body" :editor="editor"/>
|
|
||||||
<pi-asset-picker ref="pickerRef1" v-if="pickerVisible1" @success="saveSuccess1" @closed="pickerVisible1=false"
|
<pi-asset-picker ref="pickerRef1" v-if="pickerVisible1" @success="saveSuccess1" @closed="pickerVisible1=false"
|
||||||
type="image" :max="30" :multiple="true"></pi-asset-picker>
|
type="image" :max="30" :multiple="true"></pi-asset-picker>
|
||||||
<pi-asset-picker ref="pickerRef2" v-if="pickerVisible2" @success="saveSuccess2" @closed="pickerVisible2=false"
|
<pi-asset-picker ref="pickerRef2" v-if="pickerVisible2" @success="saveSuccess2" @closed="pickerVisible2=false"
|
||||||
|
|
@ -249,321 +11,78 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import piAssetPicker from '@/components/piAsset/picker'
|
import piAssetPicker from '@/components/piAsset/picker'
|
||||||
import {Editor, EditorContent} from "@tiptap/vue-3";
|
import '@wangeditor/editor/dist/css/style.css' // 引入 css
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import TextAlign from "@tiptap/extension-text-align"
|
|
||||||
import Underline from '@tiptap/extension-underline'
|
|
||||||
import Highlight from '@tiptap/extension-highlight'
|
|
||||||
import Color from '@tiptap/extension-color'
|
|
||||||
import TableRow from '@tiptap/extension-table-row'
|
|
||||||
import TableCell from '@tiptap/extension-table-cell'
|
|
||||||
import TableHeader from '@tiptap/extension-table-header'
|
|
||||||
import Image from './ext/Image'
|
|
||||||
import Video from './ext/Video'
|
|
||||||
import Indent from './ext/Indent'
|
|
||||||
import FontSize from './ext/FontSize'
|
|
||||||
import Table from './ext/Table'
|
|
||||||
import Selection from "./ext/Selection";
|
|
||||||
|
|
||||||
import {ref, onUnmounted, watch, nextTick, onMounted} from "vue";
|
import {ref, onUnmounted, watch, nextTick, onMounted} from "vue";
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {type: String, default: ''},
|
modelValue: {type: String, default: ''},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const editorRef = ref(null)
|
||||||
const pickerRef1 = ref(null)
|
const pickerRef1 = ref(null)
|
||||||
const pickerRef2 = ref(null)
|
const pickerRef2 = ref(null)
|
||||||
const colorPickerRef = ref(null)
|
|
||||||
const bgPickerRef = ref(null)
|
|
||||||
const toolbarRef = ref(null)
|
|
||||||
const pickerVisible1 = ref(false)
|
const pickerVisible1 = ref(false)
|
||||||
const pickerVisible2 = ref(false)
|
const pickerVisible2 = ref(false)
|
||||||
|
|
||||||
const predefineColors = ref([
|
const toolbarConfig = {
|
||||||
'#FFFFFF',
|
toolbarKeys: [
|
||||||
'#000000',
|
'headerSelect',
|
||||||
'#409EFF',
|
'bold',
|
||||||
'#67C23A',
|
'italic',
|
||||||
'#E6A23C',
|
'underline',
|
||||||
'#F56C6C',
|
'|',
|
||||||
'#909399',
|
'uploadImage',
|
||||||
'#303133',
|
'insertTable',
|
||||||
'#CDD0D6',
|
]
|
||||||
'#E6E8EB',
|
}
|
||||||
'#606266',
|
|
||||||
'#EBEDF0'
|
|
||||||
])
|
|
||||||
let editor = ref(null)
|
|
||||||
let fontSize = ref('12px')
|
|
||||||
let fontColor = ref('')
|
|
||||||
let bgColor = ref('')
|
|
||||||
let tag = ref('')
|
|
||||||
let tableVisible = ref(false)
|
|
||||||
let tableRow = ref(2)
|
|
||||||
let tableCol = ref(3)
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (value) => {
|
const editorConfig = {
|
||||||
const isSame = editor.value.getHTML() === value
|
placeholder: '请输入内容...',
|
||||||
if (isSame) {
|
readOnly: false,
|
||||||
return
|
autoFocus: true,
|
||||||
}
|
scroll: true,
|
||||||
editor.value.commands.setContent(value, false)
|
}
|
||||||
})
|
|
||||||
|
let html = ref("")
|
||||||
|
|
||||||
|
watch(() => props.modelValue, () => {
|
||||||
|
html.value = props.modelValue
|
||||||
|
}, {deep: true})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 保存 fakeSelection
|
|
||||||
editor.value?.on('selectionUpdate', () => {
|
|
||||||
editor.value?.commands.setSelection()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 点击外部仍保留背景
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
const el = document.querySelector('.ProseMirror')
|
|
||||||
if (!el?.contains(e.target)){
|
|
||||||
// 只是触发 view 更新,让装饰生效
|
|
||||||
editor.value?.view.dispatch(editor.value.state.tr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 点击内部清除假选中
|
|
||||||
document.querySelector('.ProseMirror')?.addEventListener('click', () => {
|
|
||||||
editor.value?.commands.clearSelection()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
editor.value.destroy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
editor.value = new Editor({
|
|
||||||
content: props.modelValue,
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Underline,
|
|
||||||
TextAlign.configure({
|
|
||||||
types: ['heading', 'paragraph'],
|
|
||||||
}),
|
|
||||||
Indent,
|
|
||||||
Highlight.configure({
|
|
||||||
multicolor: true, // 支持多种背景色
|
|
||||||
}),
|
|
||||||
Color,
|
|
||||||
FontSize,
|
|
||||||
Table,
|
|
||||||
TableRow,
|
|
||||||
TableCell.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
style: 'border: 1px solid #ccc;padding: 0.4rem;'
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TableHeader.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
style: 'border: 1px solid #ccc;padding: 0.4rem;'
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Image.configure({
|
|
||||||
inline: false,
|
|
||||||
allowBase64: true,
|
|
||||||
}),
|
|
||||||
Video.configure({
|
|
||||||
inline: false,
|
|
||||||
allowBase64: true,
|
|
||||||
}),
|
|
||||||
Selection
|
|
||||||
],
|
|
||||||
onUpdate: () => {
|
|
||||||
emit('update:modelValue', editor.value.getHTML())
|
|
||||||
},
|
|
||||||
onSelectionUpdate: () => {
|
|
||||||
// 字体大小回显
|
|
||||||
let _fontSize = editor.value.getAttributes('textStyle').fontSize
|
|
||||||
fontSize.value = _fontSize ? _fontSize : '12px'
|
|
||||||
|
|
||||||
// 标签回显
|
|
||||||
if (editor.value.isActive('paragraph')) {
|
|
||||||
tag.value = 'p'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 1})) {
|
|
||||||
tag.value = 'h1'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 2})) {
|
|
||||||
tag.value = 'h2'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 3})) {
|
|
||||||
tag.value = 'h3'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 4})) {
|
|
||||||
tag.value = 'h4'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 5})) {
|
|
||||||
tag.value = 'h5'
|
|
||||||
} else if (editor.value.isActive('heading', {level: 6})) {
|
|
||||||
tag.value = 'h6'
|
|
||||||
} else if (editor.value.isActive('codeBlock')) {
|
|
||||||
tag.value = 'pre'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreate: () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
// 初始化光标
|
|
||||||
const firstNode = editor.value.state.doc.content.firstChild
|
|
||||||
const from1 = firstNode?.content?.content?.[0]?.nodeSize ? 1 : 0
|
|
||||||
const to1 = from1 + (firstNode?.nodeSize || 0) - 2
|
|
||||||
editor.value.commands.setTextSelection({from1, to1})
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function tagChange() {
|
|
||||||
if (tag.value === 'p') {
|
|
||||||
editor.value.chain().focus().setParagraph().run()
|
|
||||||
} else if (tag.value === 'h1') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 1}).run()
|
|
||||||
} else if (tag.value === 'h2') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 2}).run()
|
|
||||||
} else if (tag.value === 'h3') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 3}).run()
|
|
||||||
} else if (tag.value === 'h4') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 4}).run()
|
|
||||||
} else if (tag.value === 'h5') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 5}).run()
|
|
||||||
} else if (tag.value === 'h6') {
|
|
||||||
editor.value.chain().focus().setHeading({level: 6}).run()
|
|
||||||
} else if (tag.value === 'pre') {
|
|
||||||
editor.value.chain().focus().setCodeBlock().run()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertTable() {
|
|
||||||
editor.value.chain().focus().insertTable({rows: tableRow.value, cols: tableCol.value}).run()
|
|
||||||
tableVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPicker1() {
|
function showPicker1() {
|
||||||
pickerVisible1.value = true
|
pickerVisible1.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
pickerRef1.value.open()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPicker2() {
|
function showPicker2() {
|
||||||
pickerVisible2.value = true
|
pickerVisible2.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
pickerRef2.value.open()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSuccess1(val) {
|
function saveSuccess1(val) {
|
||||||
val.forEach(src => {
|
val.forEach(src => {
|
||||||
editor.value.chain().focus().setImage({
|
|
||||||
src: src,
|
|
||||||
alt: ''
|
|
||||||
}).run()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSuccess2(val) {
|
function saveSuccess2(val) {
|
||||||
editor.value.chain().focus().setVideo({src: val}).run()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.pi-editor {
|
|
||||||
border: 2px solid #eee;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
visibility: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box {
|
|
||||||
background-color: #fff;
|
|
||||||
border-bottom: none;
|
|
||||||
box-shadow: 0 2px 2px -2px rgba(34, 47, 62, .1), 0 8px 8px -4px rgba(34, 47, 62, .07);
|
|
||||||
padding: 4px 0;
|
|
||||||
transition: box-shadow .5s;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group .item + .item {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.toolbar .box .group .item.color .el-color-picker__trigger) {
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.group .color-picker) {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group .el-button.active {
|
|
||||||
background: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group .el-button.active .el-icon {
|
|
||||||
color: var(--el-fill-color-blank);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group .el-button .el-icon {
|
|
||||||
color: var(--el-color-black);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .toolbar .box .group .el-button.is-disabled .el-icon {
|
|
||||||
color: var(--el-button-disabled-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pi-editor .body {
|
|
||||||
margin: 1em;
|
|
||||||
height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
resize: vertical;
|
|
||||||
|
|
||||||
:deep(ul) {
|
|
||||||
list-style-type: disc;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(ol) {
|
|
||||||
list-style-type: decimal;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(li) {
|
|
||||||
margin-bottom: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ProseMirror-selectednode) {
|
|
||||||
border: 3px solid #b4d7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.selectedCell) {
|
|
||||||
background-color: rgba(66, 185, 131, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.selection) {
|
|
||||||
background-color: rgba(180, 213, 255, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
uploadObj: api.system.file.upload,
|
uploadObj: api.system.upload,
|
||||||
fileListObj: api.system.file.list,
|
fileListObj: api.system.file.list,
|
||||||
moveFileObj: api.system.file.move,
|
moveFileObj: api.system.file.move,
|
||||||
delFileObj: api.system.file.del,
|
delFileObj: api.system.file.del,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
<el-switch v-model="form.enable" :active-value="1" :inactive-value="0"></el-switch>
|
<el-switch v-model="form.enable" :active-value="1" :inactive-value="0"></el-switch>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="memo">
|
<el-form-item label="备注" prop="memo">
|
||||||
<el-input type="textarea" v-model="form.memo" placeholder="请输入备注" clearable></el-input>
|
<pi-editor v-model="form.memo"></pi-editor>
|
||||||
|
<!-- <el-input type="textarea" v-model="form.memo" placeholder="请输入备注" clearable></el-input>-->
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -34,7 +35,7 @@
|
||||||
import {getCurrentInstance, ref} from 'vue'
|
import {getCurrentInstance, ref} from 'vue'
|
||||||
import api from "@/api/index.js"
|
import api from "@/api/index.js"
|
||||||
import piCron from "@/components/piCron"
|
import piCron from "@/components/piCron"
|
||||||
|
import piEditor from "@/components/piEditor"
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
open
|
open
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue