perf(rich-text-editor): Optimized component code to add v-model echo (#947)

This commit is contained in:
申君健 2023-11-29 14:03:42 +08:00 committed by GitHub
parent 4679b6ba6a
commit 84704ea59b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 186 additions and 196 deletions

View File

@ -1,7 +1,23 @@
<template>
<tiny-rich-text-editor></tiny-rich-text-editor>
<div>
<tiny-rich-text-editor v-model="value"></tiny-rich-text-editor>
<div class="result">
<hr />
<pre>{{ value }}</pre>
</div>
</div>
</template>
<script lang="ts" setup>
import { RichTextEditor as TinyRichTextEditor } from '@opentiny/vue'
import { ref } from 'vue'
const value = ref('你好 Opentiny!')
</script>
<style scoped>
.result {
margin-top: 16px;
font-size: 14px;
line-height: 1.3;
}
</style>

View File

@ -1,5 +1,11 @@
<template>
<tiny-rich-text-editor></tiny-rich-text-editor>
<div>
<tiny-rich-text-editor v-model="value"></tiny-rich-text-editor>
<div class="result">
<hr />
<pre>{{ value }}</pre>
</div>
</div>
</template>
<script lang="ts">
@ -8,6 +14,18 @@ import { RichTextEditor } from '@opentiny/vue'
export default {
components: {
TinyRichTextEditor: RichTextEditor
},
data() {
return {
value: '你好 Opentiny!'
}
}
}
</script>
<style scoped>
.result {
margin-top: 16px;
font-size: 14px;
line-height: 1.3;
}
</style>

View File

@ -1,6 +1,6 @@
export default {
column: '1',
owner: 'Caesar-ch',
owner: '',
demos: [
{
'demoId': 'basic-usage',
@ -8,12 +8,6 @@ export default {
'desc': { 'zh-CN': '详细用法参考如下示例', 'en-US': 'For details, see the following example.' },
'codeFiles': ['basic-usage.vue']
},
// {
// 'demoId': 'collaboration-usage',
// 'name': { 'zh-CN': '协同编辑用法', 'en-US': 'Basic Usage' },
// 'desc': { 'zh-CN': '详细用法参考如下示例', 'en-US': 'For details, see the following example.' },
// 'codeFiles': ['collaboration-usage.vue']
// },
{
'demoId': 'custom-bar-usage',
'name': { 'zh-CN': '自定义工具栏用法', 'en-US': 'Basic Usage' },
@ -37,7 +31,7 @@ export default {
'name': { 'zh-CN': 'placeholder选项用法', 'en-US': 'Basic Usage' },
'desc': { 'zh-CN': '详细用法参考如下示例', 'en-US': 'For details, see the following example.' },
'codeFiles': ['placeholder-usage.vue']
},
}
],
apis: [
{
@ -54,22 +48,13 @@ export default {
},
demoId: 'basic-usage'
},
// {
// 'name': 'collaboration',
// 'type': 'Boolean',
// 'defaultValue': 'false',
// desc: {
// 'zh-CN': '是否开启协同编辑,默认不开启',
// 'en-US': 'Whether to enable collaborative editing. It is disabled by default'
// },
// demoId: 'basic-usage'
// },
{
'name': 'customToolBar',
'type': 'Array',
'defaultValue': '[]',
desc: {
'zh-CN': '传入需要展示的工具栏按钮配置,自定义使用',
'zh-CN':
"传入需要展示的工具栏按钮配置,设置时,显示全量的工具栏。可配置的项目有:'bold','italic', 'underline', 'strike', 'quote', 'code', 'codeBlock', 'unorderedlist', 'orderedlist', 'taskList', 'subscript', 'superscript', 'undo', 'redo', 'left', 'center', 'right', 'h-box', 'font-size', 'line-height', 'highlight', 'color', 'backgroundColor', 'formatClear', 'link', 'unlink', 'img', 'table'",
'en-US': 'Pass in the toolbar button configuration that needs to be displayed, and customize the use'
},
demoId: 'basic-usage'
@ -77,7 +62,7 @@ export default {
{
'name': 'placeholder',
'type': 'Stirng',
'defaultValue': 'Write soming ...',
'defaultValue': '',
desc: {
'zh-CN': '占位符在v-model为空时展示',
'en-US': 'Placeholder, displayed when v-model is empty'

View File

@ -56,43 +56,26 @@ export const setLink = (editor) => {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
}
// table 处理逻辑
export const handleMove = (state, box) => {
return (e) => {
let { x, y } = box.value[0].getBoundingClientRect()
// 表格块移动
export const tableMouseMove = (state, vm) => (e) => {
let { x, y } = vm.$refs.tablePanelRef[0].getBoundingClientRect()
state.flagX = Math.ceil((e.x - x) / 30) // 后期改变30就可以
state.flagY = Math.ceil((e.y - y) / 30)
}
}
export const handleClickOutside = (state, box) => {
return (e) => {
if (!box.value[0]?.contains(e.target)) {
state.isShow = false
removeClickOutside(state, box)()
}
}
}
export const removeClickOutside = (state, box) => {
return () => {
window.removeEventListener('click', handleClickOutside(state, box))
}
}
export const handleClick = (state, box) => {
return (e) => {
e.stopPropagation()
if (state.isShow) {
// 点击表格块
export const tableChoose = (state, vm) => (e) => {
if (state.flagX && state.flagY) {
state.editor.chain().focus().insertTable({ rows: state.flagY, cols: state.flagX, withHeaderRow: true }).run()
}
state.flagX = 0
state.flagY = 0
removeClickOutside(state, box)()
} else {
window.addEventListener('click', handleClickOutside(state, box))
}
state.isShow = !state.isShow
}
state.isShowTable = false
}
export const toggleTablePanel = (state) => () => {
state.isShowTable = !state.isShowTable
}
export const closeTablePanel = (state) => () => {
state.isShowTable && (state.isShowTable = false)
}
// bubble菜单
export const shouldShow = ({ editor, view, state, oldState, from, to }) => {
// 仅在无序列表选中的时候才显示 气泡菜单

View File

@ -1,10 +1,10 @@
import {
handleChange,
setLink,
handleMove,
handleClickOutside,
removeClickOutside,
handleClick,
tableMouseMove,
tableChoose,
toggleTablePanel,
closeTablePanel,
shouldShow,
eventImg,
eventClick,
@ -12,24 +12,21 @@ import {
} from './index'
export const api = [
'toolBar',
'state',
'setLink',
'handleChange',
'box',
'handleMove',
'handleClickOutside',
'removeClickOutside',
'handleClick',
'tableMouseMove',
'tableChoose',
'toggleTablePanel',
'closeTablePanel',
'shouldShow',
'fontSize',
'eventImg',
'eventClick',
'Active'
]
export const renderless = (
props,
{ computed, onMounted, onBeforeUnmount, reactive, ref },
{ computed, onMounted, onBeforeUnmount, reactive, ref, markRaw },
{ vm, emit, parent },
{
Editor,
@ -54,15 +51,11 @@ export const renderless = (
CodeBlockLowlight,
lowlight,
VueNodeViewRenderer,
// CodehighComp,
// NodeViewContent,
// nodeViewProps,
// NodeViewWrapper,
Placeholder,
codeHighlight
}
) => {
let toolBar = [
let defaultToolBar = [
'bold',
'italic',
'underline',
@ -75,27 +68,24 @@ export const renderless = (
'taskList',
'subscript',
'superscript',
// 'nodeDelete',
'undo',
'redo',
'left',
'center',
'right',
'h-box',
'font-size',
'line-height',
'highlight',
'color',
'backgroundColor',
'formatClear',
'link',
'unlink',
'img',
'table'
'h-box', //
'font-size', //
'line-height', //
'highlight',
'color', //
'backgroundColor', //
'unlink', //
'img', //
'table' //
]
if (props.customToolBar) {
toolBar = props.customToolBar
}
// 自定义图片
const CustomImage = Image.extend({
addAttributes() {
@ -232,10 +222,10 @@ export const renderless = (
}
}).configure({ lowlight }),
Placeholder.configure({
placeholder: props.placeholder ?? 'Write something …'
placeholder: props.placeholder
})
],
content: '',
content: props.modelValue,
autofocus: true,
editable: true,
injectCSS: false,
@ -278,34 +268,27 @@ export const renderless = (
...props.options
}
let options = props.options ? Object.assign(defaultOptions, props.options) : defaultOptions
const editor = new Editor(options)
let options = Object.assign(defaultOptions, props.options)
const box = ref(null)
const fontSize = ref('16px')
const state = reactive({
editor: null,
editor: markRaw(new Editor(options)),
toolbar: computed(() => (props.customToolBar.length ? props.customToolBar : defaultToolBar)),
// table 变量
isShow: false,
isShowTable: false,
flagX: 0,
flagY: 0
})
state.editor = editor
const api = {
toolBar,
state,
setLink: setLink(editor),
handleChange: handleChange(editor),
setLink: setLink(state.editor),
handleChange: handleChange(state.editor),
// table处理函数
box,
handleMove: handleMove(state, box),
handleClickOutside: handleClickOutside(state, box),
removeClickOutside: removeClickOutside(state, box),
handleClick: handleClick(state, box),
tableMouseMove: tableMouseMove(state, vm),
toggleTablePanel: toggleTablePanel(state),
closeTablePanel: closeTablePanel(state),
tableChoose: tableChoose(state, vm),
// bubble 菜单
shouldShow,
//
fontSize,
shouldShow: shouldShow,
eventImg,
eventClick,
Active
@ -313,5 +296,6 @@ export const renderless = (
onBeforeUnmount(() => {
state.editor.destroy()
})
return api
}

View File

@ -11,7 +11,8 @@
font-style: italic;
}
a, a:hover {
a,
a:hover {
text-decoration: underline;
}
@ -63,14 +64,15 @@
&__toolbar button {
height: 24px;
padding: .25rem;
margin-right: .25rem;
padding: 0.25rem;
margin-right: 0.25rem;
border: none;
border-radius: .4rem;
border-radius: 0.4rem;
background: transparent;
cursor: pointer;
svg, input {
svg,
input {
cursor: pointer;
}
@ -128,8 +130,8 @@
.h-options {
position: absolute;
padding: .15rem;
background-color: var(--ti-rich-text-editor-options-bg-color);
padding: 0.15rem;
background-color: var(--ti-rich-text-edito-options-bg-color);
left: 0px;
display: none;
border-radius: var(--ti-rich-text-editor-options-border-radius);
@ -141,20 +143,21 @@
margin: 0;
text-align: center;
line-height: var(--ti-common-line-height-3);
svg, path {
svg,
path {
fill: black;
}
&:hover {
background-color: var(--ti-rich-text-editor-options-item-bg-color);
svg, path {
fill: var(--ti-rich-text-editor-options-item-hover-color);
background-color: var(--ti-rich-text-edito-options-item-bg-color);
svg,
path {
fill: var(--ti-rich-text-edito-options-item-hover-color);
}
}
}
}
&:hover {
.h-options {
display: flex;
flex-direction: column;
@ -170,7 +173,6 @@
// 表格区域样式
.tiny-rich-text-editor {
.table-button {
.table-box {
display: flex;
position: relative;
@ -179,7 +181,7 @@
display: inline-block;
}
.table-option {
.table-panel {
position: absolute;
background: white;
left: 0;
@ -201,8 +203,8 @@
}
.isActive {
background-color: rgba(32, 122, 183, .5);
border-color: rgba(32, 122, 183, .5);
background-color: rgba(32, 122, 183, 0.5);
border-color: rgba(32, 122, 183, 0.5);
}
}
}
@ -215,7 +217,6 @@
}
}
}
}
// 颜色选择样式
@ -235,7 +236,6 @@
}
&:hover {
#tiny-back-color,
#tiny-color {
background-color: #d2e4ff;
@ -291,8 +291,8 @@
.line-height-options {
position: absolute;
padding: .15rem;
background-color: var(--ti-rich-text-editor-options-bg-color);
padding: 0.15rem;
background-color: var(--ti-rich-text-edito-options-bg-color);
left: 0px;
display: none;
border-radius: var(--ti-rich-text-editor-options-border-radius);
@ -326,7 +326,6 @@
.tiny-rich-text-editor {
.bubble-menu {
button {
background-color: #f1f3f5;
}
@ -393,7 +392,7 @@
}
.ProseMirror {
>*+* {
> * + * {
margin-top: 0.75em;
}
@ -446,8 +445,8 @@
}
pre {
background: #0D0D0D;
color: #FFF;
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
@ -467,12 +466,12 @@
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0D0D0D, 0.1);
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
hr {
border: none;
border-top: 2px solid rgba(#0D0D0D, 0.1);
border-top: 2px solid rgba(#0d0d0d, 0.1);
margin: 2rem 0;
}
}
@ -507,7 +506,7 @@
box-sizing: border-box;
position: relative;
>* {
> * {
margin-bottom: 0;
}
}
@ -521,7 +520,7 @@
.selectedCell:after {
z-index: 2;
position: absolute;
content: "";
content: '';
left: 0;
right: 0;
top: 0;
@ -557,8 +556,7 @@
}
.ProseMirror {
ul[data-type="taskList"] {
ul[data-type='taskList'] {
list-style: none;
padding: 0;
@ -566,13 +564,13 @@
display: flex;
align-items: center;
>label {
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
>div {
> div {
flex: 1 1 auto;
}
}
@ -580,7 +578,7 @@
}
.ProseMirror {
>*+* {
> * + * {
margin-top: 0.75em;
}
@ -589,7 +587,7 @@
select {
position: absolute;
top: .5rem;
top: 0.5rem;
right: 0.5rem;
border: 0;
background: gray;
@ -600,7 +598,7 @@
pre {
background: #0d0d0d;
color: #fff;
font-family: "JetBrainsMono", monospace;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;

View File

@ -1,4 +1,14 @@
<script lang="jsx">
<template>
<NodeViewWrapper class="code-block">
<select contenteditable="false" v-model="selectedLanguage">
<option value="null">auto</option>
<option disabled></option>
<option v-for="(item, index) in languages" :value="item" :key="index">{{ item }}</option>
</select>
<pre><code><NodeViewContent /></code></pre>
</NodeViewWrapper>
</template>
<script>
import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue'
import { defineComponent } from '@opentiny/vue-common'
@ -23,27 +33,6 @@ export default defineComponent({
this.updateAttributes({ language })
}
}
},
render() {
return (
<NodeViewWrapper class="code-block">
<select contenteditable="false" v-model={this.selectedLanguage}>
<option value="null">auto</option>
<option disabled></option>
{this.languages.map((item, index) => (
<option value={item} key={index}>
{' '}
{item}{' '}
</option>
))}
</select>
<pre>
<code>
<NodeViewContent />
</code>
</pre>
</NodeViewWrapper>
)
}
})
</script>

View File

@ -2,12 +2,8 @@
<div class="tiny-rich-text-editor">
<div class="tiny-rich-text-editor__toolbar">
<!-- starter-kit功能区 -->
<template v-for="item in toolBar">
<button
v-if="(item.name ?? item) === 'font-size'"
:title="t('ui.richTextEditor.fontSize')"
class="font-size-box"
>
<template v-for="item in state.toolbar">
<button v-if="item === 'font-size'" :title="t('ui.richTextEditor.fontSize')" class="font-size-box">
<TinyIconRichTextFontSize></TinyIconRichTextFontSize>
<div class="font-size-options">
<button @click="state.editor.chain().focus().setSize({ size: 12 }).run()">12px</button>
@ -20,7 +16,7 @@
</div>
</button>
<button
v-else-if="(item.name ?? item) === 'line-height'"
v-else-if="item === 'line-height'"
class="line-height-button"
:title="t('ui.richTextEditor.lineHeight')"
>
@ -34,7 +30,7 @@
<button class="line-2.5" @click.stop="state.editor.chain().focus().setP({ level: 2.5 }).run()">2.5</button>
</div>
</button>
<button v-else-if="(item.name ?? item) === 'h-box'" :title="t('ui.richTextEditor.hBox')" class="h-box">
<button v-else-if="item === 'h-box'" :title="t('ui.richTextEditor.hBox')" class="h-box">
<div class="h-ico">
<TinyIconRichTextHeading></TinyIconRichTextHeading>
</div>
@ -62,7 +58,7 @@
</button>
</div>
</button>
<button v-else-if="(item.name ?? item) === 'img'" :title="t('ui.richTextEditor.img')" class="image-button">
<button v-else-if="item === 'img'" :title="t('ui.richTextEditor.img')" class="image-button">
<TinyIconRichTextImage></TinyIconRichTextImage>
<div class="img-option">
<div class="img-item">
@ -74,7 +70,7 @@
</div>
</div>
</button>
<button v-else-if="(item.name ?? item) === 'color'" :title="t('ui.richTextEditor.color')" class="color-button">
<button v-else-if="item === 'color'" :title="t('ui.richTextEditor.color')" class="color-button">
<label for="tiny-color">
<TinyIconRichTextColor></TinyIconRichTextColor>
</label>
@ -85,7 +81,7 @@
/>
</button>
<button
v-else-if="(item.name ?? item) === 'backgroundColor'"
v-else-if="item === 'backgroundColor'"
:title="t('ui.richTextEditor.backgroundColor')"
class="color-button"
>
@ -98,12 +94,18 @@
@input="state.editor.chain().focus().setBackColor({ bgColor: $event.target.value }).run()"
/>
</button>
<button v-else-if="(item.name ?? item) === 'table'" :title="t('ui.richTextEditor.table')" class="table-button">
<div class="table-box" @click="handleClick">
<button v-else-if="item === 'table'" :title="t('ui.richTextEditor.table')" class="table-button">
<div class="table-box" v-clickoutside="closeTablePanel" @click="toggleTablePanel">
<div class="table-icon">
<TinyIconRichTextTable></TinyIconRichTextTable>
</div>
<div class="table-option" ref="box" v-if="state.isShow" @mousemove="handleMove">
<div
class="table-panel"
ref="tablePanelRef"
v-if="state.isShowTable"
@mousemove="tableMouseMove"
@click.stop="tableChoose"
>
<div class="table-row">
<div class="item" :class="{ isActive: 1 <= state.flagX && 1 <= state.flagY }"></div>
<div class="item" :class="{ isActive: 2 <= state.flagX && 1 <= state.flagY }"></div>
@ -132,7 +134,7 @@
</div>
</button>
<button
v-else-if="(item.name ?? item) === 'unlink'"
v-else-if="item === 'unlink'"
:title="t('ui.richTextEditor.unlink')"
@click="eventClick(state.editor, item)"
:disabled="!state.editor?.isActive(Active(item))"
@ -142,7 +144,7 @@
</button>
<button
v-else
:title="t(`ui.richTextEditor.${item.name ?? item}`)"
:title="t(`ui.richTextEditor.${item}`)"
@click="eventClick(state.editor, item)"
:class="{ 'is-active': state.editor?.isActive(Active(item)) }"
>
@ -224,7 +226,7 @@
</button>
</BubbleMenu>
</div>
<div class="tiny-rich-text-editor__container" :style="{ fontSize }">
<div class="tiny-rich-text-editor__container">
<EditorContent :editor="state.editor"></EditorContent>
</div>
</div>
@ -282,15 +284,7 @@ import {
iconRichTextUnderline,
iconRichTextUndo
} from '@opentiny/vue-icon'
import {
Editor,
EditorContent,
BubbleMenu,
VueNodeViewRenderer
// NodeViewContent,
// nodeViewProps,
// NodeViewWrapper
} from '@tiptap/vue'
import { Editor, EditorContent, BubbleMenu, VueNodeViewRenderer } from '@tiptap/vue'
import StarterKit from '@tiptap/starter-kit'
//
@ -332,6 +326,9 @@ import TextAlign from '@tiptap/extension-text-align'
// code high light
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
// Placeholder
import Placeholder from '@tiptap/extension-placeholder'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
@ -352,7 +349,30 @@ function initLowLight() {
}
/* @__PURE__ */
initLowLight()
// import Codehighlight from './code-highlight.vue'
import { $props, setup, defineComponent, $prefix, directive } from '@opentiny/vue-common'
import '@opentiny/vue-theme/rich-text-editor/index.less'
import Clickoutside from '@opentiny/vue-renderless/common/deps/clickoutside'
export const richTextEditorProps = {
...$props,
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
customToolBar: {
type: Array,
default: []
},
options: {
type: Object,
default: {}
}
}
export default defineComponent({
name: $prefix + 'RichTextEditor',
@ -367,7 +387,8 @@ export default defineComponent({
'destroy',
'update'
],
props: [...props, 'modelValue', 'collaboration', 'placeholder', 'customToolBar', 'options'],
directives: directive({ Clickoutside }),
props: richTextEditorProps,
components: {
EditorContent,
BubbleMenu,
@ -449,10 +470,6 @@ export default defineComponent({
CodeBlockLowlight,
lowlight,
VueNodeViewRenderer,
// CodehighComp: VueNodeViewRenderer(Codehighlight(NodeViewContent, nodeViewProps, NodeViewWrapper)),
// NodeViewContent,
// nodeViewProps,
// NodeViewWrapper,
Placeholder,
codeHighlight
}