freeleaps_frontend/frontend/src/components/FreeleapsEditor.vue
2024-07-02 05:52:58 +08:00

623 lines
16 KiB
Vue

<template>
<div class="freeleaps-editor">
<div
class="editor-body"
:contenteditable="!disabled"
spellcheck="false"
ref="editor"
v-html="content"
@blur="updateAction"
@mousedown.stop="selectionStart"
@mouseup.stop="selectionEnd"
/>
<div v-if="!disabled" class="editor-control" :style="editorCtrlStyle">
<transition-group appear name="fade-transform" mode="out-in">
<template v-if="!inputing">
<div v-for="(item, index) in iconList" :key="index" class="editor-item">
<button
class="item-icon"
:class="{
activity: commandStates.indexOf(item.type) !== -1,
last: index === iconList.length - 1
}"
:data-info="item.name"
@click="iconClick($event, item.type)"
:data-bs-toggle="item.drop ? 'dropdown' : ''"
data-bs-auto-close="outside"
aria-expanded="false"
>
<svg-icon :icon="item.icon" class-name="icon" />
</button>
<div class="dropmenu drop-style" v-if="item.type === 'style'">
<ul>
<li>
<a href="#" @click="iconClick($event, 'p', 'style')"><p>Text</p></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'pre', 'style')"><pre>code</pre></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'blockquote', 'style')"
><blockquote>引用</blockquote></a
>
</li>
<li>
<a href="#" @click="iconClick($event, 'h1', 'style')"><h1>Caption 1</h1></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'h2', 'style')"><h2>Caption 2</h2></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'h3', 'style')"><h3>Caption 3</h3></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'h4', 'style')"><h4>Caption 4</h4></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'h5', 'style')"><h5>Caption 5</h5></a>
</li>
<li>
<a href="#" @click="iconClick($event, 'h6', 'style')"><h6>Caption 6</h6></a>
</li>
</ul>
</div>
<div class="dropmenu drop-style" v-if="item.type === 'alignjustify'">
<ul>
<li>
<a href="#" @click="iconClick($event, 'justifyCenter', 'alignjustify')">
<i class="iconfont icon-aligncenter"></i>
<span>middle</span>
</a>
</li>
<li>
<a href="#" @click="iconClick($event, 'justifyLeft', 'alignjustify')">
<i class="iconfont icon-alignleft"></i>
<span>left aligned</span>
</a>
</li>
<li>
<a href="#" @click="iconClick($event, 'justifyRight', 'alignjustify')">
<i class="iconfont icon-alignright"></i>
<span>right aligned</span>
</a>
</li>
<li>
<a href="#" @click="iconClick($event, 'justifyFull', 'alignjustify')">
<i class="iconfont icon-alignjustify"></i>
<span>default aligned</span>
</a>
</li>
</ul>
</div>
</div>
</template>
<template v-if="inputing">
<div class="editor-item">
<button class="item-icon item-icon-back" @click="backAction" data-info="back">
<svg-icon icon="fe-back" class-name="icon" />
</button>
</div>
<div class="editor-item">
<input type="text" v-model="linkUrl" autofocus @keydown.enter="inputAction">
<svg-icon icon="msg-enter" class-name="item-enter-icon" />
</div>
</template>
</transition-group>
</div>
</div>
</template>
<script>
import SvgIcon from '@/components/SvgIcon.vue'
import { websiteValidator } from '@/utils/validator'
export default {
name: 'FreeleapsEditor',
props: {
content: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:content'],
components: { SvgIcon },
data() {
return {
selectedRange: '',
editorCtrlStyle: {},
commandStates: [],
inputing: false,
linkUrl: '',
iconList: [
// {
// name: 'lable', // hover name
// type: 'style', // click event handler
// icon: 'fe-paragraph', // icon style
// drop: true, // If there is drop menu
// canChoose: true, // chosen or not
// },
{
name: 'bold',
type: 'bold',
icon: 'fe-bold',
drop: false,
canChoose: true
},
{
name: 'italic',
type: 'italic',
icon: 'fe-italic',
drop: false,
canChoose: true
},
{
name: 'underline',
type: 'underline',
icon: 'fe-underline',
drop: false,
canChoose: true
},
// {
// name: 'strike',
// type: 'strike',
// icon: 'fe-strike',
// drop: false,
// canChoose: true,
// },
// {
// name: 'clear-format',
// type: 'clear',
// icon: 'fe-clear',
// drop: false,
// canChoose: false,
// },
// {
// name: 'font-color',
// type: 'fontFamily',
// icon: 'fe-char',
// drop: false,
// canChoose: true,
// },
{
name: 'unordered-list',
type: 'unorderedlist',
icon: 'fe-unorderedlist',
drop: false,
canChoose: true
},
{
name: 'ordered list',
type: 'orderedlist',
icon: 'fe-orderedlist',
drop: false,
canChoose: true
},
{
name: 'link',
type: 'link',
icon: 'fe-link',
drop: false,
canChoose: true
},
// {
// name: 'align-justify',
// type: 'alignjustify',
// icon: 'fe-alignjustify',
// drop: true,
// canChoose: true,
// }
]
}
},
methods: {
selectionStart() {
this.$refs.editor.blur()
},
selectionEnd(e) {
const sel = window.getSelection()
if (sel && sel.type === 'Range') {
// this.selectedRange = sel.getRangeAt(0)
// this.restoreSelection()
// console.log('window.getSelection',this.selectedRange)
// console.log('is bold ? ', this.queryCommandState(sel.getRangeAt(0), 'bold'))
// this.iconClick(null, 'bold')
this.editorCtrlStyle = { display: 'flex', top: `${e.layerY}px`, left: `${e.layerX}px` }
} else {
this.selectedRange = null
this.editorCtrlStyle = { display: 'none' }
this.inputing = false
this.linkUrl = ''
// this.commandStates = []
}
},
queryCommandState(range, command) {
const container = document.createElement('span')
container.style.display = 'none'
container.appendChild(range.cloneContents())
console.log('queryCommandState', container)
// range
document.body.appendChild(container)
const state = document.queryCommandState(command)
console.log('queryCommandIndeterm ', document.queryCommandIndeterm('bold'))
console.log('queryCommandState ', document.queryCommandState('bold'))
// range.collapse(true);
container.remove()
return state
},
iconClick(event, type, dropType) {
event.preventDefault()
this.$refs.editor.focus()
this.selectedRange = this.getSelect()
this.restoreSelection()
if (type === 'link') {
this.inputing = true
return
}
this.changeStyle(type)
this.$nextTick(() => {
if (dropType) {
type = dropType
}
const i = this.commandStates.indexOf(type)
if (i === -1) {
this.commandStates.push(type)
} else {
this.commandStates.splice(i, 1)
}
})
},
backAction() {
this.inputing = false
this.linkUrl = ''
this.$refs.editor.focus()
this.restoreSelection()
},
inputAction() {
const error = websiteValidator.validate(this.linkUrl)
if (error) {
alert(error)
return
}
const a_node = document.createElement('a')
a_node.setAttribute('href', this.linkUrl)
this.selectedRange.surroundContents(a_node)
this.backAction()
this.$refs.editor.blur()
this.linkUrl = ''
this.editorCtrlStyle = { display: 'none' }
},
getSelect() {
if (window.getSelection) {
let sel = window.getSelection()
if (sel.rangeCount > 0) {
return sel.getRangeAt(0)
}
} else if (document.selection) {
return document.selection.createRange()
}
return null
},
// change the selection's style
changeStyle(type) {
switch (type) {
case 'bold':
document.execCommand('bold', false)
break
case 'underline':
document.execCommand('underline', false)
break
case 'strike':
document.execCommand('strikeThrough', false)
break
case 'italic':
document.execCommand('italic', false)
break
case 'clear':
document.execCommand('removeFormat', false)
break
case 'unorderedlist':
document.execCommand('insertUnorderedList', false)
break
case 'orderedlist':
document.execCommand('insertorderedList', false)
break
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
case 'p':
case 'pre':
case 'blockquote':
document.execCommand('formatBlock', false, type)
break
case 'justifyCenter':
case 'justifyFull':
case 'justifyLeft':
case 'justifyRight':
document.execCommand(type, false)
break
default:
console.log('none')
}
this.$refs.editor.blur()
// window.getSelection().removeAllRanges()
},
restoreSelection() {
let selection = window.getSelection()
if (this.selectedRange) {
try {
selection.removeAllRanges()
} catch (ex) {
document.body.createTextRange().select()
document.selection.empty()
}
selection.addRange(this.selectedRange)
}
},
updateAction($event) {
let html = $event.target.innerHTML || ''
this.$emit('update:content', html)
}
}
}
</script>
<style lang="scss" scoped>
.freeleaps-editor {
position: relative;
min-width: 100px;
background-color: #fff;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
h1,
h2,
h3,
h4,
h5,
h6,
.h1,
.h2,
.h3,
.h4,
.h5,
.h6,
p,
a {
font-family: inherit;
font-weight: 500;
line-height: 1.1;
color: inherit;
margin: 0;
text-decoration: none;
}
code,
kbd,
pre,
samp {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.editor-control {
display: none;
width: fit-content;
height: 26px;
background-color: #f8f8f9;
position: absolute;
}
.dropmenu {
display: none;
position: absolute;
top: 32px;
left: 0;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
font-size: 14px;
text-align: left;
list-style: none;
background-color: #fff;
background-clip: padding-box;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
ul {
padding: 10px;
margin-bottom: 0;
}
ul li {
text-align: left;
list-style: none;
a {
display: block;
padding: 5px 10px;
white-space: nowrap;
}
}
}
.drop-align {
min-width: 100px;
}
.editor-body {
height: 300px;
padding: 10px;
color: #000;
background-color: #fff;
overflow: auto;
outline: none;
text-align: left;
border-radius: 4px;
position: relative;
p {
font-size: 14px;
color: #68747f;
margin: 0 0 10px;
}
}
}
.editor-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: pointer;
input {
height: 24px;
border: 1px solid #e1e1e1;
box-shadow: none;
outline: none;
padding-left: 5px;
padding-right: 24px;
&:focus {
border-color: $primary;
}
}
.item-enter-icon {
position: absolute;
right: 2px;
width: 16px;
height: 16px;
padding: 3px;
background-color: #e1e1e1;
border-radius: 3px;
}
.item-icon {
position: relative;
width: 50px;
height: 16px;
font-size: 16px;
color: #9ea2af;
font-weight: normal;
white-space: nowrap;
vertical-align: middle;
touch-action: manipulation;
cursor: pointer;
user-select: none;
outline: none;
transition: all 0.1s ease-out;
border: none;
line-height: 16px;
background: transparent;
border-right: 1px solid #e7e8eb;
&.last {
border-right: 0;
}
&::after {
position: absolute;
top: 0;
content: attr(data-info);
top: 40px;
left: 20px;
padding: 5px 8px;
border-radius: 4px;
white-space: nowrap;
line-height: 1.5;
font-size: 13px;
color: #fff;
background: rgba(0, 0, 0, 0.8);
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
visibility: hidden;
opacity: 0.9;
letter-spacing: 1px;
z-index: 9999;
}
&::before {
position: absolute;
content: '';
top: 35px;
left: 20px;
width: 0;
height: 0;
margin: 0 0 0 -6px;
font-size: 0;
color: rgba(0, 0, 0, 0.8);
border-bottom: 6px solid currentColor;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
visibility: hidden;
opacity: 0.9;
z-index: 9999;
}
&:hover,
&.activity {
color: black;
}
&:hover:after,
&:hover:before {
visibility: visible;
}
&.activity + .dropmenu {
display: block;
}
&.item-icon-back {
color: black;
}
}
}
</style>
<style>
.freeleaps-editor blockquote {
padding: 10px 20px;
font-size: 17.5px;
border-left: 5px solid #f86466;
background: white;
}
.freeleaps-editor pre {
display: block;
padding: 9.5px;
font-size: 13px;
line-height: 1.42857143;
color: #333;
word-break: break-all;
word-wrap: break-word;
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 4px;
}
.editor-body blockquote {
margin-bottom: 30px;
}
.editor-body pre {
margin-bottom: 10px;
}
.editor-body ul {
padding-left: 40px;
list-style-type: disc;
}
.editor-body ol {
padding-left: 40px;
}
/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 300;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-50px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(50px);
}
.fade-transform-active {
position: absolute;
}
</style>