This commit is contained in:
zhang zhuo 2025-11-20 11:58:44 +08:00
parent f55a608034
commit 1c9f69a53b
14 changed files with 38 additions and 1282 deletions

View File

@ -32,7 +32,7 @@
<div id="app" class="pi">
<div class="app-loading">
<div class="app-loading__logo">
<img src="images/logo.png"/>
<img src="/images/logo.png"/>
</div>
<div class="app-loading__loader"></div>
<div class="app-loading__title">%VITE_APP_TITLE%</div>

View File

@ -11,6 +11,8 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "1.12.0",
"cron-parser": "^4.9",
"cropperjs": "^1.6.2",

View File

@ -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}`,
}
},
},
}
},
})

View File

@ -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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="form.height">
<template #suffix>&nbsp;</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>

View File

@ -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)
},
})

View File

@ -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
},
}
},
})

View File

@ -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
},
}
},
})

View File

@ -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>

View File

@ -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)
}
})

View File

@ -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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="form.height">
<template #suffix>&nbsp;</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>

View File

@ -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)
},
})

View File

@ -1,245 +1,7 @@
<template>
<div class="pi-editor">
<div class="toolbar" ref="toolbarRef">
<div class="box">
<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>&nbsp;</template>
</el-input>
</el-col>
<el-col :span="12">
<el-input v-model="tableCol">
<template #suffix>&nbsp;</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"/>
<Toolbar :editor="editorRef" :defaultConfig="toolbarConfig"/>
<Editor v-model="html" :defaultConfig="editorConfig" @onCreated="editorRef = $event"/>
<pi-asset-picker ref="pickerRef1" v-if="pickerVisible1" @success="saveSuccess1" @closed="pickerVisible1=false"
type="image" :max="30" :multiple="true"></pi-asset-picker>
<pi-asset-picker ref="pickerRef2" v-if="pickerVisible2" @success="saveSuccess2" @closed="pickerVisible2=false"
@ -249,321 +11,78 @@
<script setup>
import piAssetPicker from '@/components/piAsset/picker'
import {Editor, EditorContent} from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
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 '@wangeditor/editor/dist/css/style.css' // css
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import {ref, onUnmounted, watch, nextTick, onMounted} from "vue";
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {type: String, default: ''},
})
const editorRef = ref(null)
const pickerRef1 = ref(null)
const pickerRef2 = ref(null)
const colorPickerRef = ref(null)
const bgPickerRef = ref(null)
const toolbarRef = ref(null)
const pickerVisible1 = ref(false)
const pickerVisible2 = ref(false)
const predefineColors = ref([
'#FFFFFF',
'#000000',
'#409EFF',
'#67C23A',
'#E6A23C',
'#F56C6C',
'#909399',
'#303133',
'#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)
const toolbarConfig = {
toolbarKeys: [
'headerSelect',
'bold',
'italic',
'underline',
'|',
'uploadImage',
'insertTable',
]
}
watch(() => props.modelValue, (value) => {
const isSame = editor.value.getHTML() === value
if (isSame) {
return
}
editor.value.commands.setContent(value, false)
})
const editorConfig = {
placeholder: '请输入内容...',
readOnly: false,
autoFocus: true,
scroll: true,
}
let html = ref("")
watch(() => props.modelValue, () => {
html.value = props.modelValue
}, {deep: true})
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(() => {
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() {
pickerVisible1.value = true
nextTick(() => {
pickerRef1.value.open()
})
}
function showPicker2() {
pickerVisible2.value = true
nextTick(() => {
pickerRef2.value.open()
})
}
function saveSuccess1(val) {
val.forEach(src => {
editor.value.chain().focus().setImage({
src: src,
alt: ''
}).run()
})
}
function saveSuccess2(val) {
editor.value.chain().focus().setVideo({src: val}).run()
}
</script>
<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>

View File

@ -1,7 +1,7 @@
import api from "@/api";
export default {
uploadObj: api.system.file.upload,
uploadObj: api.system.upload,
fileListObj: api.system.file.list,
moveFileObj: api.system.file.move,
delFileObj: api.system.file.del,

View File

@ -20,7 +20,8 @@
<el-switch v-model="form.enable" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<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>
<template #footer>
@ -34,7 +35,7 @@
import {getCurrentInstance, ref} from 'vue'
import api from "@/api/index.js"
import piCron from "@/components/piCron"
import piEditor from "@/components/piEditor"
defineExpose({
open