完成富文本编辑器的移植

master
yutent 2023-09-18 17:29:20 +08:00
parent e057349494
commit d5e8ddb613
2 changed files with 512 additions and 37 deletions

View File

@ -4,7 +4,19 @@
* @date 2023/09/14 16:49:15
*/
import { css, raw, html, Component, nextTick, styleMap } from 'wkit'
import {
css,
raw,
html,
Component,
bind,
unbind,
nextTick,
styleMap,
classMap,
outsideClick,
clearOutsideClick
} from 'wkit'
import ICONS from './svg.js'
import '../form/input.js'
import '../form/button.js'
@ -34,11 +46,13 @@ const DEFAULT_TOOLS = [
'delete',
'ordered',
'unordered',
'table',
'left',
'center',
'right',
'link',
'image'
'image',
'fullscreen'
]
const COLORS = [
@ -56,7 +70,18 @@ const COLORS = [
'#000000'
]
// 获取一维数组转二维的行
function getY(i) {
return (i / 9) >> 0
}
//获取一维数组转二维的列
function getX(i) {
return i % 9
}
class Editor extends Component {
static watches = ['value']
static props = {
toolbar: {
type: String,
@ -70,9 +95,18 @@ class Editor extends Component {
}
}
},
value: 'str!',
readonly: false,
disabled: false
readonly: {
type: Boolean,
observer(v) {
this.#updateStat()
}
},
disabled: {
type: Boolean,
observer(v) {
this.#updateStat()
}
}
}
static styles = [
@ -84,6 +118,7 @@ class Editor extends Component {
max-height: 720px;
border-radius: 3px;
transition: box-shadow 0.15s linear;
background: var(--wc-editor-background, #fff);
}
table {
width: 100%;
@ -132,7 +167,7 @@ class Editor extends Component {
display: flex;
flex-direction: column;
width: 100%;
border: 1px solid #e7e8eb;
border: 1px solid var(--wc-editor-border-color, var(--color-grey-2));
border-radius: inherit;
font-size: 14px;
}
@ -141,7 +176,7 @@ class Editor extends Component {
height: 34px;
padding: 5px;
line-height: 24px;
border-bottom: 1px solid #e7e8eb;
border-bottom: 1px solid var(--color-grey-1);
span {
position: relative;
@ -189,10 +224,13 @@ class Editor extends Component {
&::-webkit-scrollbar {
display: none;
}
.wrapper {
min-height: 100%;
}
}
.is-vertical {
// visibility: hidden;
position: absolute;
right: 0;
top: 0;
@ -200,17 +238,15 @@ class Editor extends Component {
justify-content: flex-end;
width: 10px;
height: 100%;
// opacity: 0;
transition: opacity 0.3s linear, visibility 0.3s linear;
.thumb {
display: block;
width: 6px;
height: 90px;
height: 0;
border-radius: 5px;
background: rgba(44, 47, 53, 0.25);
cursor: default;
transition: width 0.1s linear, height 0.1s linear;
transition: width 0.1s linear;
&:hover {
width: 10px;
@ -222,6 +258,7 @@ class Editor extends Component {
.typearea {
width: 100%;
height: 100%;
min-height: 54px;
outline: none;
text-wrap: wrap;
word-break: break-all;
@ -233,10 +270,10 @@ class Editor extends Component {
`,
css`
:host(:hover) {
.is-vertical {
visibility: visible;
opacity: 1;
:host([readonly]) {
.editor {
cursor: default;
opacity: 0.8;
}
}
:host([disabled]) {
@ -245,13 +282,6 @@ class Editor extends Component {
opacity: 0.6;
}
}
:host([readonly]) {
.editor {
cursor: default;
opacity: 0.8;
}
}
:host([readonly]),
:host([disabled]) {
.toolbar {
@ -264,6 +294,17 @@ class Editor extends Component {
:host(:focus-within) {
box-shadow: 0 0 0 2px var(--color-plain-a);
}
:host(.fullscreen) {
position: fixed;
top: 0;
left: 0;
z-index: 9;
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
}
`,
css`
@ -271,7 +312,8 @@ class Editor extends Component {
.font-layer,
.color-layer,
.link-layer {
.link-layer,
.table-layer {
visibility: hidden;
position: absolute;
left: 0;
@ -326,6 +368,27 @@ class Editor extends Component {
}
}
.table-layer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
left: 240px;
width: 200px;
height: 200px;
padding: 2px;
background: #fff;
span {
width: 20px;
height: 20px;
background: var(--color-plain-1);
&.active {
background: rgba(77, 182, 172, 0.3);
}
}
}
.link-layer {
display: flex;
flex-direction: column;
@ -342,6 +405,384 @@ class Editor extends Component {
]
#toolbar = []
#value = ''
#cache = { bar: 0, y: 0 }
#gridx = 0
#gridy = 0
#select = null
get value() {
let html = this.$refs.editor?.innerHTML || ''
html = html.replace(/<\!\-\-(.*?)\-\->/g, '')
if (~html.indexOf('<table>') && !html.startsWith('<style>table')) {
html =
'<style>table{border-spacing:0;border-collapse:collapse;}table tr{background:#fff;}table thead tr{background:#f3f5fb;}table th,table td{padding:6px 12px;border:1px solid #dae1e9;}table th{font-weight: bold;}</style>' +
html
}
return html
}
set value(val) {
if (this.$refs.editor) {
this.#value = val
this.$refs.editor.innerHTML = val
} else {
nextTick(_ => (this.value = val))
}
}
__init__() {
//
let { outer, inner, thumb } = this.$refs
let height = outer.offsetHeight
let scrollHeight = inner.scrollHeight + 10
let bar = 50 // 滚动条的高度
bar = (height * (height / scrollHeight)) >> 0
if (bar < 50) {
bar = 50
}
// 100%或主体高度比滚动条还短时不显示
if (bar >= height) {
bar = 0
}
this.#cache.bar = bar
thumb.style.height = bar + 'px'
}
#updateStat() {
if (this.$refs.editor) {
if (this.readOnly || this.disabled) {
this.$refs.editor.removeAttribute('contenteditable')
} else {
this.$refs.editor.setAttribute('contenteditable', '')
}
} else {
nextTick(_ => this.#updateStat())
}
}
#fetchScroll(moveY) {
let { bar } = this.#cache
let { outer, thumb, inner } = this.$refs
let height = outer.offsetHeight
let scrollHeight = inner.scrollHeight + 10
if (moveY < 0) {
moveY = 0
} else if (moveY > height - bar) {
moveY = height - bar
}
inner.scrollTop = (scrollHeight - height) * (moveY / (height - bar))
thumb.style.transform = `translateY(${moveY}px)`
return moveY
}
#hideLayers() {
this.$refs.font.classList.remove('fadein')
this.$refs.color.classList.remove('fadein')
this.$refs.link.classList.remove('fadein')
this.$refs.table.classList.remove('fadein')
}
// 处理图片
#handleImage(ev, file) {
if (ev && ev.type === 'change') {
file = ev.target.files[0]
ev.target.value = ''
}
this.$emit('upload', {
detail: {
file,
send: link => {
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION.image, link)
this.saveSelection()
}
}
})
}
#handlePaste(ev) {
let html = ev.clipboardData.getData('text/html')
let txt = ev.clipboardData.getData('text/plain')
let items = ev.clipboardData.items
// 先文件判断, 避免右键单击复制图片时, 当成html处理
if (items && items.length) {
let blob = null
for (let it of items) {
if (it.type.indexOf('image') > -1) {
blob = it.getAsFile()
}
}
if (blob) {
return this.#handleImage(null, blob)
}
}
if (html) {
html = html
.replace(/\t/g, ' ')
.replace(/<\/?(meta|link|script)[^>]*?>/g, '')
.replace(/<!--[\w\W]*?-->/g, '')
.replace(
/<a[^>]*? href\s?=\s?["']?([^"']*)["']?[^>]*?>/g,
'<a href="$1">'
)
.replace(
/<img[^>]*? src\s?=\s?["']?([^"']*)["']?[^>]*?>/g,
'<img src="$1">'
)
.replace(/<(?!a|img)([\w\-]+)[^>]*>/g, '<$1>')
.replace(/<xml[^>]*?>[\w\W]*?<\/xml>/g, '')
.replace(/<style>[\w\W]*?<\/style>/g, '')
return this.exec('insertHtml', html)
}
if (txt) {
return this.exec('insertText', txt)
}
}
#toolbarClick(ev) {
var target = ev.target
var act
this.restoreSelection()
if (ev.target === ev.currentTarget) {
return
}
if (this.readOnly || this.disabled) {
return
}
while (target.tagName !== 'SPAN') {
target = target.parentNode
}
act = target.dataset.act
this.#hideLayers()
switch (act) {
case 'font':
case 'color':
case 'link':
case 'table':
this.$refs[act].classList.add('fadein')
break
case 'image':
// 这里不作任何处理
break
case 'copy':
navigator.clipboard.writeText(this.value)
break
case 'fullscreen':
this.classList.toggle('fullscreen')
break
default:
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION[act])
this.saveSelection()
break
}
}
#chnageFontSize(ev) {
if (ev.target === ev.currentTarget) {
return
}
this.$refs.font.classList.remove('fadein')
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION.font, ev.target.dataset.size)
this.saveSelection()
}
#chnageColor(ev) {
if (ev.target === ev.currentTarget) {
return
}
this.$refs.color.classList.remove('fadein')
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION.color, ev.target.dataset.value)
this.saveSelection()
}
#insertLink(ev) {
let value = this.$refs.linkinput.value.trim()
if (value) {
this.$refs.link.classList.remove('fadein')
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION.link, value)
this.saveSelection()
this.$refs.linkinput.value = ''
}
}
#insertTable(ev) {
let th = `<th>&nbsp;</th>`.repeat(this.#gridx + 1)
let td = `<td>&nbsp;</td>`.repeat(this.#gridx + 1)
this.exec(
'insertHtml',
`<br>
<table>
<thead><tr>${th}</tr></thead>
<tbody>${`<tr>${td}</tr>`.repeat(this.#gridy + 1)}</tbody>
</table><br>`
)
this.$refs.table.classList.remove('fadein')
}
#tableSelect(ev) {
let grids = Array.from(this.$refs.table.children)
if (ev.type === 'mousemove') {
if (ev.target === ev.currentTarget) {
return
}
let idx = +ev.target.dataset.idx
let x = getX(idx)
let y = getY(idx)
if (x === this.#gridx && y === this.#gridy) {
return
}
this.#gridx = x
this.#gridy = y
for (let i = 0; i < grids.length; i++) {
let _x = getX(i)
let _y = getY(i)
grids[i].classList.toggle('active', _x <= x && _y <= y)
}
} else {
grids.forEach(it => it.classList.remove('active'))
}
}
// 保存选中
saveSelection() {
var gs = this.root.getSelection()
if (gs.getRangeAt && gs.rangeCount) {
this.#select = gs.getRangeAt(0)
}
}
// 清除选中并重置选中
restoreSelection() {
var gs = this.root.getSelection()
if (this.#select) {
try {
gs.removeAllRanges()
} catch (err) {}
gs.addRange(this.#select)
}
}
// 执行命令
exec(cmd, val = '') {
document.execCommand(cmd, false, val)
}
mounted() {
let startY,
moveY,
mousemoveFn = ev => {
let { y } = this.#cache
if (startY !== undefined) {
moveY = this.#fetchScroll(y + ev.pageY - startY)
}
},
mouseupFn = ev => {
startY = undefined
this.#cache.y = moveY || 0
delete this._active
unbind(document, 'mousemove', mousemoveFn)
unbind(document, 'mouseup', mouseupFn)
}
this.exec('styleWithCSS', true)
bind(this.$refs.thumb, 'mousedown', ev => {
startY = ev.pageY
this._active = true
this.#hideLayers()
bind(document, 'mousemove', mousemoveFn)
bind(document, 'mouseup', mouseupFn)
})
// 鼠标滚动事件
bind(this.$refs.inner, 'scroll', ev => {
// 拖拽时忽略滚动事件
if (this._active) {
return
}
let { bar, y } = this.#cache
let { outer, thumb, inner } = this.$refs
let height = outer.offsetHeight
let scrollHeight = inner.scrollHeight + 10
let scrollTop = inner.scrollTop
this.#hideLayers()
// y轴 都为0时, 不作任何处理
if (bar === 0) {
return
}
// 修正滚动条的位置
// 滚动比例 y 滚动条的可移动距离
let fixedY = ~~((scrollTop / (scrollHeight - height)) * (height - bar))
if (fixedY !== y) {
this.#cache.y = fixedY
thumb.style.transform = `translateY(${fixedY}px)`
}
})
this._clickoutsideFn = outsideClick(this, _ => this.#hideLayers())
this._scrollFn = new ResizeObserver(this.__init__.bind(this))
this._scrollFn.observe(this.$refs.cont)
this._inputFn = new MutationObserver(_ => {
this.$emit('input')
})
this._inputFn.observe(this.$refs.editor, {
childList: true,
subtree: true,
characterData: true
})
}
unmounted() {
this._scrollFn?.disconnect()
this._inputFn?.disconnect()
clearOutsideClick(this._clickoutsideFn)
}
render() {
toolbar = [
@ -351,7 +792,7 @@ class Editor extends Component {
return html`
<div class="editor">
<section class="toolbar">
<section class="toolbar" @click=${this.#toolbarClick}>
${toolbar.map(
it => html`
<span data-act=${it}>
@ -359,35 +800,65 @@ class Editor extends Component {
<path d=${ICONS[it]} />
</svg>
${it === 'image'
? html`<input type="file" accept="image/*" />`
? html`<input
type="file"
:disabled=${this.readOnly || this.disabled}
@change=${this.#handleImage}
accept="image/*"
/>`
: ''}
</span>
`
)}
</section>
<div class="scroll-outerbox">
<div class="scroll-innerbox">
<div contenteditable="true" class="typearea" spellcheck="false">
${raw(this.value)}
<div class="scroll-outerbox" ref="outer">
<div class="scroll-innerbox" ref="inner">
<div class="wrapper" ref="cont" @click=${this.#hideLayers}>
<div
ref="editor"
contenteditable="true"
class="typearea"
@mouseleave=${this.saveSelection}
@paste.prevent=${this.#handlePaste}
></div>
</div>
</div>
<div class="is-vertical noselect">
<span ref="y" class="thumb"></span>
<span class="thumb" ref="thumb"></span>
</div>
</div>
<div class="font-layer noselect">
<div
class="font-layer noselect"
ref="font"
@click=${this.#chnageFontSize}
>
<span data-size="6">6号字体</span>
<span data-size="5">5号字体</span>
<span data-size="4">4号字体</span>
<span data-size="3">3号字体</span>
<span data-size="2">2号字体</span>
</div>
<div class="color-layer noselect">
<div
class="color-layer noselect"
ref="color"
@click=${this.#chnageColor}
>
${COLORS.map(c => html`<span data-value=${c}></span>`)}
</div>
<div class="link-layer noselect">
<wc-input label="请输入链接地址"></wc-input>
<wc-button color="teal" size="mini">插入</wc-button>
<div
class="table-layer noselect"
ref="table"
@click=${this.#insertTable}
@mousemove=${this.#tableSelect}
@mouseleave=${this.#tableSelect}
>
${Array(81)
.fill(0)
.map((_, n) => html`<span data-idx=${n}></span>`)}
</div>
<div class="link-layer noselect" ref="link">
<wc-input ref="linkinput" label="请输入链接地址"></wc-input>
<wc-button size="m" @click=${this.#insertLink}>插入</wc-button>
</div>
</div>
`

View File

@ -24,8 +24,12 @@ export default {
'M232 872h560v-80H232v80m280-160c132.4 0 240-107.6 240-240V152H652v320c0 77.2-62.8 140-140 140s-140-62.8-140-140V152H272v320c0 132.4 107.6 240 240 240z',
center:
'M128 128h768v85.333H128V128m170.667 170.667h426.666V384H298.667v-85.333M128 469.333h768v85.334H128v-85.334M298.667 640h426.666v85.333H298.667V640M128 810.667h768V896H128v-85.333z',
table:
'M860 159H164c-19.88 0-36 16.93-36 37.82v630.36c0 20.89 16.12 37.82 36 37.82h696c19.88 0 36-16.93 36-37.82V196.82c0-20.89-16.12-37.82-36-37.82z m-422 26.74c20.98 0 38 17.01 38 38 0 20.98-17.02 38-38 38-20.99 0-38-17.01-38-38s17.02-38 38-38z m-108 0c20.98 0 38 17.01 38 38 0 20.98-17.02 38-38 38-20.99 0-38-17.01-38-38s17.01-38 38-38z m-108 0c20.98 0 38 17.01 38 38 0 20.98-17.01 38-38 38s-38-17.01-38-38 17.01-38 38-38zM664 808V664h176v144H664z m-64-144v144H424V664h176z m-416 0h176v144H184V664z m480-190h176v144H664V474zM424 618V474h176v144H424z m-64 0H184V474h176v144z m480-338v144H664V280h176z m-240 0v144H424V280h176z m-385.54 0H360v144H184V280h30.46z',
unordered:
'M298.667 213.333v85.334H896v-85.334M298.667 554.667H896v-85.334H298.667m0 341.334H896v-85.334H298.667m-128-14.08c-31.574 0-56.747 25.6-56.747 56.747s25.6 56.747 56.747 56.747c31.146 0 56.746-25.6 56.746-56.747s-25.173-56.747-56.746-56.747m0-519.253c-35.414 0-64 28.587-64 64s28.586 64 64 64c35.413 0 64-28.587 64-64s-28.587-64-64-64m0 256c-35.414 0-64 28.587-64 64s28.586 64 64 64c35.413 0 64-28.587 64-64s-28.587-64-64-64z',
right:
'M128 128h768v85.333H128V128m256 170.667h512V384H384v-85.333M128 469.333h768v85.334H128v-85.334M384 640h512v85.333H384V640M128 810.667h768V896H128v-85.333z'
'M128 128h768v85.333H128V128m256 170.667h512V384H384v-85.333M128 469.333h768v85.334H128v-85.334M384 640h512v85.333H384V640M128 810.667h768V896H128v-85.333z',
fullscreen:
'M597.33 449.42l40.02 41.25 94.25-93.75 36.4 35V320H654.14l37.44 36.3-94.25 93.12z m-170.66 124.6l-40.41-40.68-93.53 93.93L256 592.76l0.95 111.24 113.96-0.9-37.78-35.78 93.54-93.3z m211.91-40.69l-41.25 40.02 93.75 94.24-35 36.4H768V590.14l-36.3 37.44-93.12-94.25z m-252.86-42.66l40.95-40.23-93.84-93.87L367.56 320l-111.56 0.5 0.48 113.91 36.02-37.62 93.22 93.88zM848 832H176c-26.47 0-48-19.57-48-43.64V235.64c0-24.06 21.53-43.64 48-43.64h672c26.47 0 48 19.58 48 43.64v552.73c0 24.06-21.53 43.63-48 43.63zM206.55 256c-8.02 0-14.55 5.74-14.55 12.8v486.4c0 7.06 6.53 12.8 14.55 12.8h610.91c8.02 0 14.55-5.74 14.55-12.8V268.8c0-7.06-6.53-12.8-14.55-12.8H206.55z'
}