perf: 优化任务描述编辑器

This commit is contained in:
kuaifan 2023-08-09 21:58:22 +08:00
parent ee708d1d1b
commit a3c509da83
7 changed files with 333 additions and 215 deletions

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="teditor-wrapper" @click="onClickWrap" @touchstart="onTouchstart"> <div class="teditor-wrapper">
<div class="teditor-box" :class="[!inline && spinShow ? 'teditor-loadstyle' : 'teditor-loadedstyle']"> <div class="teditor-box" :class="[!inline && spinShow ? 'teditor-loadstyle' : 'teditor-loadedstyle']">
<template v-if="inline"> <template v-if="inline">
<div ref="myTextarea" :id="id" v-html="spinShow ? '' : content"></div> <div ref="myTextarea" :id="id" v-html="spinShow ? '' : content"></div>
@ -35,20 +35,6 @@
:on-format-error="handleFormatError" :on-format-error="handleFormatError"
:on-exceeded-size="handleMaxSize" :on-exceeded-size="handleMaxSize"
:before-upload="handleBeforeUpload"/> :before-upload="handleBeforeUpload"/>
<div class="teditor-operate" :style="operateStyles" v-show="operateVisible">
<Dropdown
trigger="custom"
:visible="operateVisible"
@on-clickoutside="operateVisible = false"
transfer>
<div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div>
<DropdownMenu slot="list">
<DropdownItem @click.native="onFull">{{ editTitle || $L('编辑') }}</DropdownItem>
<DropdownItem v-if="operateLink" @click.native="onLinkPreview">{{ $L('打开链接') }}</DropdownItem>
<DropdownItem v-if="operateImg" @click.native="onImagePreview">{{ $L('查看图片') }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div> </div>
<Spin fix v-if="uploadIng > 0"> <Spin fix v-if="uploadIng > 0">
<Icon type="ios-loading" class="icon-loading"></Icon> <Icon type="ios-loading" class="icon-loading"></Icon>
@ -75,6 +61,8 @@
import {mapState} from "vuex"; import {mapState} from "vuex";
import {languageType} from "../language"; import {languageType} from "../language";
const windowTouch = "ontouchend" in document
export default { export default {
name: 'TEditor', name: 'TEditor',
components: {ImgUpload}, components: {ImgUpload},
@ -110,9 +98,25 @@
]; ];
} }
}, },
menubar: {
type: String,
default: () => {
if (windowTouch) {
return 'edit insert format tools';
} else {
return 'file edit view insert format tools table';
}
},
},
toolbar: { toolbar: {
type: String, type: String,
default: ' undo redo | styleselect | uploadImages | uploadFiles | bold italic underline forecolor backcolor | alignleft aligncenter alignright | bullist numlist outdent indent | link image emoticons media codesample | preview screenload', default: () => {
if (windowTouch) {
return 'uploadImages | bold italic underline | forecolor backcolor | screenload';
} else {
return 'undo redo | styleselect | uploadImages | uploadFiles | bold italic underline forecolor backcolor | alignleft aligncenter alignright | bullist numlist outdent indent | link image emoticons media codesample | preview screenload';
}
},
}, },
options: { options: {
type: Object, type: Object,
@ -132,7 +136,10 @@
}, },
readOnly: { readOnly: {
type: Boolean, type: Boolean,
default: false // windowTouch true readOnly true default: false
},
readOnlyFull: {
default: null
}, },
autoSize: { autoSize: {
type: Boolean, type: Boolean,
@ -150,9 +157,6 @@
type: String, type: String,
default: '' default: ''
}, },
scrollHideOperateClassName: { // class
type: String,
},
}, },
data() { data() {
return { return {
@ -171,30 +175,14 @@
actionUrl: $A.apiUrl('system/fileupload'), actionUrl: $A.apiUrl('system/fileupload'),
maxSize: 10240, maxSize: 10240,
operateStyles: {},
operateVisible: false,
operateLink: null,
operateImg: null, operateImg: null,
timer: null, timer: null,
listener: null,
}; };
}, },
mounted() { mounted() {
this.content = this.value; this.content = this.value;
this.init(); this.init();
//
if (this.scrollHideOperateClassName) {
let parent = this.$parent.$el.parentNode;
while (parent) {
if (parent.classList.contains(this.scrollHideOperateClassName)) {
this.listener = parent;
parent.addEventListener("scroll", this.onTouchstart);
break;
}
parent = parent.parentNode;
}
}
}, },
activated() { activated() {
this.content = this.value; this.content = this.value;
@ -203,9 +191,6 @@
deactivated() { deactivated() {
this.destroy(); this.destroy();
}, },
beforeDestroy() {
this.listener?.removeEventListener("scroll", this.onTouchstart);
},
destroyed() { destroyed() {
this.destroy(); this.destroy();
}, },
@ -230,9 +215,6 @@
}, },
readOnly(value) { readOnly(value) {
if (this.editor !== null) { if (this.editor !== null) {
if (this.windowTouch) {
return;
}
if (value) { if (value) {
this.editor.setMode('readonly'); this.editor.setMode('readonly');
} else { } else {
@ -267,7 +249,6 @@
this.editorT = null; this.editorT = null;
} }
this.spinShow = true; this.spinShow = true;
this.operateVisible = false;
$A(this.$refs.myTextarea).show(); $A(this.$refs.myTextarea).show();
}, 500); }, 500);
}, },
@ -301,8 +282,9 @@
selector: (isFull ? '#T_' : '#') + this.id, selector: (isFull ? '#T_' : '#') + this.id,
base_url: $A.originUrl('js/tinymce'), base_url: $A.originUrl('js/tinymce'),
language: lang, language: lang,
toolbar: this.toolbar,
plugins: this.plugin(isFull), plugins: this.plugin(isFull),
menubar: this.menubar,
toolbar: this.toolbar,
placeholder: isFull && this.placeholderFull ? this.placeholderFull : this.placeholder, placeholder: isFull && this.placeholderFull ? this.placeholderFull : this.placeholder,
save_onsavecallback: (e) => { save_onsavecallback: (e) => {
this.$emit('editorSave', e); this.$emit('editorSave', e);
@ -424,7 +406,8 @@
editor.on('Init', (e) => { editor.on('Init', (e) => {
this.editorT = editor; this.editorT = editor;
this.editorT.setContent(this.content); this.editorT.setContent(this.content);
if (this.readOnly) { const readOnly = this.readOnlyFull === null ? this.readOnly : this.readOnlyFull;
if (readOnly) {
this.editorT.setMode('readonly'); this.editorT.setMode('readonly');
} else { } else {
this.editorT.setMode('design'); this.editorT.setMode('design');
@ -448,13 +431,12 @@
this.spinShow = false; this.spinShow = false;
this.editor = editor; this.editor = editor;
this.editor.setContent(this.content); this.editor.setContent(this.content);
if (this.readOnly || this.windowTouch) { if (this.readOnly) {
this.editor.setMode('readonly'); this.editor.setMode('readonly');
this.updateTouchContent();
} else { } else {
this.editor.setMode('design'); this.editor.setMode('design');
} }
this.$emit('editorInit', this.editor); this.$emit('on-editor-init', this.editor);
}); });
editor.on('KeyUp', (e) => { editor.on('KeyUp', (e) => {
if (this.editor !== null) { if (this.editor !== null) {
@ -518,14 +500,8 @@
this.$emit('input', this.content); this.$emit('input', this.content);
this.editorT.destroy(); this.editorT.destroy();
this.editorT = null; this.editorT = null;
//
if (this.windowTouch) {
setTimeout(() => {
this.updateTouchContent();
this.$emit('on-blur');
}, 100);
}
} }
this.$emit('on-transfer-change', visible);
}, },
getEditor() { getEditor() {
@ -627,12 +603,6 @@
return imgs; return imgs;
}, },
onLinkPreview() {
if (this.operateLink) {
window.open(this.operateLink);
}
},
onImagePreview() { onImagePreview() {
const array = this.getValueImages(); const array = this.getValueImages();
if (array.length === 0) { if (array.length === 0) {
@ -643,74 +613,6 @@
this.$store.dispatch("previewImage", {index, list: array}) this.$store.dispatch("previewImage", {index, list: array})
}, },
onClickWrap(event) {
if (!this.windowTouch) {
return
}
event.stopPropagation()
this.operateVisible = false;
this.operateLink = event.target.tagName === "A" ? event.target.href : null;
this.operateImg = event.target.tagName === "IMG" ? event.target.src : null;
this.$nextTick(() => {
const rect = this.$el.getBoundingClientRect();
this.operateStyles = {
left: `${event.clientX - rect.left}px`,
top: `${event.clientY - rect.top}px`,
}
this.operateVisible = true;
})
},
onTouchstart() {
if (!this.windowTouch) {
return
}
this.operateVisible = false;
},
updateTouchContent() {
if (!this.windowTouch) {
return
}
this.$nextTick(_ => {
if (!this.editor) {
return;
}
if (!this.placeholder || this.content) {
this.editor.bodyElement.removeAttribute("data-mce-placeholder");
this.editor.bodyElement.removeAttribute("aria-placeholder");
} else {
this.editor.bodyElement.setAttribute("data-mce-placeholder", this.placeholder);
this.editor.bodyElement.setAttribute("aria-placeholder", this.placeholder);
}
this.updateTouchLink(0);
})
},
updateTouchLink(timeout) {
if (!this.windowTouch) {
return
}
setTimeout(_ => {
if (!this.editor) {
return;
}
this.editor.bodyElement.querySelectorAll("a").forEach(item => {
if (item.__dataMceClick !== true) {
item.__dataMceClick = true;
item.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
this.onClickWrap(event);
})
}
})
if (timeout < 300) {
this.updateTouchLink(timeout + 100);
}
}, timeout)
},
/********************文件上传部分************************/ /********************文件上传部分************************/
handleProgress(event, file) { handleProgress(event, file) {

View File

@ -0,0 +1,251 @@
<template>
<div class="task-editor" @click="onClickWrap" @touchstart="onTouchstart">
<TEditor
ref="desc"
v-model="content"
:plugins="plugins"
:options="options"
:option-full="optionFull"
:placeholder="placeholder"
:placeholderFull="placeholderFull"
:readOnly="windowTouch"
:readOnlyFull="false"
@on-blur="onBlur"
@on-editor-init="onEditorInit"
@on-transfer-change="onTransferChange"
inline/>
<div class="task-editor-operate" :style="operateStyles" v-show="operateVisible">
<Dropdown
trigger="custom"
:visible="operateVisible"
@on-clickoutside="operateVisible = false"
transfer>
<div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div>
<DropdownMenu slot="list">
<DropdownItem @click.native="onEditing">{{ $L('编辑描述') }}</DropdownItem>
<DropdownItem v-if="operateLink" @click.native="onLinkPreview">{{ $L('打开链接') }}</DropdownItem>
<DropdownItem v-if="operateImg" @click.native="onImagePreview">{{ $L('查看图片') }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.task-editor {
position: relative;
.task-editor-operate {
position: absolute;
top: 0;
left: 0;
width: 1px;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}
</style>
<script>
import TEditor from "./TEditor.vue";
export default {
name: 'TEditorTask',
components: {TEditor},
props: {
value: {
default: ''
},
placeholder: {
default: ''
},
placeholderFull: {
default: ''
},
},
data() {
return {
content: this.value,
plugins: [
'advlist autolink lists link image charmap print preview hr anchor pagebreak',
'searchreplace visualblocks visualchars code',
'insertdatetime media nonbreaking save table directionality',
'emoticons paste codesample',
'autoresize'
],
options: {
statusbar: false,
menubar: false,
autoresize_bottom_margin: 2,
min_height: 200,
max_height: 380,
contextmenu: 'bold italic underline forecolor backcolor | link | codesample | uploadImages imagePreview | preview screenload',
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
extended_valid_elements: 'a[href|title|target=_blank]',
toolbar: false
},
optionFull: {
menubar: 'file edit view',
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
extended_valid_elements: 'a[href|title|target=_blank]',
toolbar: 'uploadImages | bold italic underline | forecolor backcolor'
},
operateStyles: {},
operateVisible: false,
operateLink: null,
operateImg: null,
listener: null,
};
},
mounted() {
let parent = this.$parent.$el.parentNode;
while (parent) {
if (parent.classList.contains(".ivu-modal-wrap")) {
this.listener = parent;
parent.addEventListener("scroll", this.onTouchstart);
break;
}
parent = parent.parentNode;
}
},
beforeDestroy() {
this.listener?.removeEventListener("scroll", this.onTouchstart);
},
computed: {
editor() {
return this.$refs.desc.editor;
},
},
watch: {
value(val) {
this.content = val;
},
content(val) {
this.$emit('input', val);
}
},
methods: {
getContent() {
return this.$refs.desc.getContent();
},
onEditing() {
this.$refs.desc.onFull()
},
onBlur() {
this.$emit('on-blur');
},
onEditorInit(editor) {
this.updateTouchContent();
this.$emit('on-editor-init', editor);
},
onTransferChange(visible) {
if (visible) {
return
}
if (!this.windowTouch) {
return
}
setTimeout(_ => {
this.updateTouchContent();
this.onBlur();
}, 100);
},
onClickWrap(event) {
if (!this.windowTouch) {
return
}
event.stopPropagation()
this.operateVisible = false;
this.operateLink = event.target.tagName === "A" ? event.target.href : null;
this.operateImg = event.target.tagName === "IMG" ? event.target.src : null;
this.$nextTick(() => {
const rect = this.$el.getBoundingClientRect();
this.operateStyles = {
left: `${event.clientX - rect.left}px`,
top: `${event.clientY - rect.top}px`,
}
this.operateVisible = true;
})
},
onTouchstart() {
if (!this.windowTouch) {
return
}
this.operateVisible = false;
},
updateTouchContent() {
if (!this.windowTouch) {
return
}
this.$nextTick(_ => {
if (!this.editor) {
return;
}
if (this.content) {
this.editor.bodyElement.removeAttribute("data-mce-placeholder");
this.editor.bodyElement.removeAttribute("aria-placeholder");
} else {
this.editor.bodyElement.setAttribute("data-mce-placeholder", this.placeholder);
this.editor.bodyElement.setAttribute("aria-placeholder", this.placeholder);
}
this.updateTouchLink(0);
})
},
updateTouchLink(timeout) {
if (!this.windowTouch) {
return
}
setTimeout(_ => {
if (!this.editor) {
return;
}
this.editor.bodyElement.querySelectorAll("a").forEach(item => {
if (item.__dataMceClick !== true) {
item.__dataMceClick = true;
item.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
this.onClickWrap(event);
})
}
})
if (timeout < 300) {
this.updateTouchLink(timeout + 100);
}
}, timeout)
},
onLinkPreview() {
if (this.operateLink) {
window.open(this.operateLink);
}
},
onImagePreview() {
const array = this.$refs.desc.getValueImages();
if (array.length === 0) {
$A.messageWarning("没有可预览的图片")
return;
}
let index = Math.max(0, array.findIndex(item => item.src === this.operateImg));
this.$store.dispatch("previewImage", {index, list: array})
},
}
}
</script>

View File

@ -25,16 +25,11 @@
enterkeyhint="done" enterkeyhint="done"
@on-keydown="onKeydown"/> @on-keydown="onKeydown"/>
</div> </div>
<div class="desc"> <TEditorTask
<TEditor class="desc"
v-model="addData.content" v-model="addData.content"
:plugins="taskPlugins"
:options="taskOptions"
:option-full="taskOptionFull"
:placeholder="$L(windowLandscape ? '详细描述,选填...(点击右键使用工具栏)' : '详细描述,选填...')" :placeholder="$L(windowLandscape ? '详细描述,选填...(点击右键使用工具栏)' : '详细描述,选填...')"
:placeholderFull="$L('详细描述...')" :placeholderFull="$L('详细描述...')"/>
inline/>
</div>
<div class="advanced-option" :class="{'advanced-open': advanced}"> <div class="advanced-option" :class="{'advanced-open': advanced}">
<Button @click="advanced=!advanced">{{$L('高级选项')}}</Button> <Button @click="advanced=!advanced">{{$L('高级选项')}}</Button>
<ul class="advanced-priority"> <ul class="advanced-priority">
@ -209,14 +204,14 @@
</template> </template>
<script> <script>
import TEditor from "../../../components/TEditor";
import {mapState} from "vuex"; import {mapState} from "vuex";
import UserSelect from "../../../components/UserSelect.vue"; import UserSelect from "../../../components/UserSelect.vue";
import TaskExistTips from "./TaskExistTips.vue"; import TaskExistTips from "./TaskExistTips.vue";
import TEditorTask from "../../../components/TEditorTask.vue";
export default { export default {
name: "TaskAdd", name: "TaskAdd",
components: {UserSelect, TEditor, TaskExistTips}, components: {TEditorTask, UserSelect, TaskExistTips},
props: { props: {
value: { value: {
type: Boolean, type: Boolean,
@ -252,29 +247,6 @@ export default {
advanced: false, advanced: false,
subName: '', subName: '',
taskPlugins: [
'advlist autolink lists link image charmap print preview hr anchor pagebreak',
'searchreplace visualblocks visualchars code',
'insertdatetime media nonbreaking save table directionality',
'emoticons paste codesample',
'autoresize'
],
taskOptions: {
statusbar: false,
menubar: false,
autoresize_bottom_margin: 2,
min_height: 200,
max_height: 380,
contextmenu: 'bold italic underline forecolor backcolor | codesample | uploadImages imagePreview | preview screenload',
valid_elements : 'a[href|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
toolbar: false
},
taskOptionFull: {
menubar: 'file edit view',
valid_elements : 'a[href|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
toolbar: 'uploadImages | bold italic underline forecolor backcolor | codesample | preview screenload'
},
taskTimeOpen: false, taskTimeOpen: false,
timeOptions: {shortcuts: $A.timeOptionShortcuts()}, timeOptions: {shortcuts: $A.timeOptionShortcuts()},

View File

@ -135,19 +135,12 @@
@on-blur="updateBlur('name')" @on-blur="updateBlur('name')"
@on-keydown="onNameKeydown"/> @on-keydown="onNameKeydown"/>
</div> </div>
<div class="desc"> <TEditorTask
<TEditor
ref="desc" ref="desc"
class="desc"
:value="taskContent" :value="taskContent"
:plugins="taskPlugins"
:options="taskOptions"
:option-full="taskOptionFull"
:edit-title="$L('编辑描述')"
:placeholder="$L('详细描述...')" :placeholder="$L('详细描述...')"
scroll-hide-operate-class-name="task-modal" @on-blur="updateBlur('content')"/>
@on-blur="updateBlur('content')"
inline/>
</div>
<Form class="items" label-position="left" label-width="auto" @submit.native.prevent> <Form class="items" label-position="left" label-width="auto" @submit.native.prevent>
<FormItem v-if="taskDetail.p_name"> <FormItem v-if="taskDetail.p_name">
<div class="item-label" slot="label"> <div class="item-label" slot="label">
@ -463,7 +456,6 @@
<script> <script>
import {mapState} from "vuex"; import {mapState} from "vuex";
import TEditor from "../../../components/TEditor";
import TaskPriority from "./TaskPriority"; import TaskPriority from "./TaskPriority";
import TaskUpload from "./TaskUpload"; import TaskUpload from "./TaskUpload";
import DialogWrapper from "./DialogWrapper"; import DialogWrapper from "./DialogWrapper";
@ -473,10 +465,12 @@ import TaskMenu from "./TaskMenu";
import ChatInput from "./ChatInput"; import ChatInput from "./ChatInput";
import UserSelect from "../../../components/UserSelect.vue"; import UserSelect from "../../../components/UserSelect.vue";
import TaskExistTips from "./TaskExistTips.vue"; import TaskExistTips from "./TaskExistTips.vue";
import TEditorTask from "../../../components/TEditorTask.vue";
export default { export default {
name: "TaskDetail", name: "TaskDetail",
components: { components: {
TEditorTask,
UserSelect, UserSelect,
TaskExistTips, TaskExistTips,
ChatInput, ChatInput,
@ -485,7 +479,6 @@ export default {
DialogWrapper, DialogWrapper,
TaskUpload, TaskUpload,
TaskPriority, TaskPriority,
TEditor
}, },
props: { props: {
taskId: { taskId: {
@ -553,31 +546,6 @@ export default {
sendLoad: 0, sendLoad: 0,
openLoad: 0, openLoad: 0,
taskPlugins: [
'advlist autolink lists link image charmap print preview hr anchor pagebreak',
'searchreplace visualblocks visualchars code',
'insertdatetime media nonbreaking save table directionality',
'emoticons paste codesample',
'autoresize'
],
taskOptions: {
statusbar: false,
menubar: false,
autoresize_bottom_margin: 2,
min_height: 200,
max_height: 380,
contextmenu: 'bold italic underline forecolor backcolor | link | codesample | uploadImages imagePreview | preview screenload',
valid_elements : 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
extended_valid_elements : 'a[href|title|target=_blank]',
toolbar: false
},
taskOptionFull: {
menubar: 'file edit view',
valid_elements : 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,img[src|alt|witdh|height],pre[class],code',
extended_valid_elements : 'a[href|title|target=_blank]',
toolbar: 'uploadImages | bold italic underline | forecolor backcolor'
},
dialogDrag: false, dialogDrag: false,
imageAttachment: true, imageAttachment: true,
receiveTaskSubscribe: null, receiveTaskSubscribe: null,

View File

@ -37,6 +37,12 @@
} }
} }
.tox-tbtn__select-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tox-tbtn--bespoke { .tox-tbtn--bespoke {
.tox-tbtn__select-label { .tox-tbtn__select-label {
width: auto; width: auto;
@ -88,6 +94,18 @@
} }
} }
} }
.tox-tbtn__select-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tox-tbtn--bespoke {
.tox-tbtn__select-label {
width: auto;
}
}
} }
} }
} }
@ -106,16 +124,6 @@
position: relative; position: relative;
} }
.teditor-operate {
position: absolute;
top: 0;
left: 0;
width: 1px;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.upload-control { .upload-control {
display: none; display: none;
width: 0; width: 0;

View File

@ -690,6 +690,24 @@ body {
} }
} }
} }
@media (max-width: 576px) {
&.lr,
&.auto {
> ul {
> li:not(.search-button) {
.search-content {
.ivu-input-wrapper,
.ivu-select {
width: auto;
}
}
}
}
}
}
} }
.search-expand { .search-expand {

View File

@ -69,7 +69,6 @@
} }
.desc { .desc {
margin-top: 24px; margin-top: 24px;
overflow: auto;
div[contenteditable="true"] { div[contenteditable="true"] {
outline: none outline: none
} }