ui/src/meditor/index.js

946 lines
20 KiB
JavaScript

/**
* {}
* @author yutent<yutent.io@gmail.com>
* @date 2023/09/14 16:49:15
*/
import {
css,
raw,
html,
Component,
bind,
unbind,
nextTick,
styleMap,
classMap,
outsideClick,
clearOutsideClick
} from 'wkit'
import { renderToolbar, DEFAULT_TOOLS, html2md } from './helper.js'
import Addon from './addon.js'
import markd from '../markd/index.js'
import '../form/input.js'
import '../form/button.js'
import '../code/index.js'
const COLORS = [
'#f3f5fb',
'#dae1e9',
'#62778d',
'#58d68d',
'#3fc2a7',
'#52a3de',
'#ac61ce',
'#ffb618',
'#e67e22',
'#ff5061',
'#ff0000',
'#000000'
]
// 获取一维数组转二维的行
function getY(i) {
return (i / 9) >> 0
}
//获取一维数组转二维的列
function getX(i) {
return i % 9
}
function getIndex(x, y) {
return x + y * 9
}
class MEditor extends Component {
static watches = ['value']
static props = {
toolbar: {
type: String,
default: null,
attribute: false,
observer(v) {
if (v === null) {
this.#toolbar = [...DEFAULT_TOOLS]
} else {
this.#toolbar = v
.split(',')
.filter(it => it)
.map(it => it.trim())
}
}
},
readonly: false,
disabled: false
}
static styles = [
css`
:host {
display: flex;
min-width: 200px;
max-height: 720px;
border-radius: 3px;
transition: box-shadow 0.15s linear;
background: var(--wc-meditor-background, #fff);
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
`,
css`
.meditor {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
border: 1px solid var(--wc-editor-border-color, var(--color-grey-2));
border-radius: inherit;
font-size: 14px;
background: #fff;
}
.toolbar {
display: none;
width: 100%;
height: 34px;
padding: 5px;
line-height: 24px;
border-bottom: 1px solid var(--color-grey-1);
span {
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
margin: 0 3px;
border-radius: 3px;
color: var(--color-grey-3);
.icon {
overflow: hidden;
width: 70%;
height: 70%;
fill: currentColor;
color: #62778d;
}
&:hover,
&.active {
background: var(--color-plain-1);
}
&.active {
color: var(--color-teal-1);
}
}
&.active {
display: flex;
}
}
`,
css`
.editor-outbox {
flex: 1;
display: flex;
width: 100%;
min-height: 300px;
border-radius: 3px;
.editor,
.preview {
flex: 1;
flex-shrink: 0;
}
.editor {
height: 100%;
padding: 5px 8px;
line-height: 1.5;
border: 0;
font-size: 14px;
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
color: var(--color-dark-1);
background: none;
outline: none;
resize: none;
cursor: inherit;
&::placeholder {
color: var(--color-grey-1);
}
}
.preview {
overflow: hidden;
overflow-y: auto;
display: none;
padding: 6px 12px;
border-left: 1px solid var(--color-plain-2);
&.active {
display: block;
}
}
}
`,
css`
:host([readonly]) {
.editor {
cursor: default;
opacity: 0.8;
}
}
:host([disabled]) {
.editor {
cursor: not-allowed;
opacity: 0.6;
}
}
:host([readonly]),
:host([disabled]) {
.toolbar {
span:hover {
background: none;
}
}
}
: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`
.addon-table {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 222px;
height: 222px;
padding: 2px;
background: #fff;
li {
display: flex;
justify-content: space-between;
height: 20px;
span {
width: 20px;
height: 20px;
background: var(--color-plain-1);
&.active {
background: rgba(77, 182, 172, 0.3);
}
}
}
}
.addon-header {
width: 108px;
height: 190px;
padding: 5px 0;
line-height: 30px;
user-select: none;
background: #fff;
li {
display: flex;
align-items: center;
width: 100%;
height: 30px;
padding: 0 12px;
transition: background 0.1s ease-in-out;
cursor: pointer;
.icon {
width: 14px;
height: 14px;
margin-right: 8px;
}
&:hover {
background: var(--color-plain-1);
}
}
}
.addon-link {
width: 320px;
padding: 8px 5px;
background: #fff;
font-size: 13px;
li {
display: flex;
align-items: center;
padding: 0 12px;
margin-top: 6px;
label {
width: 60px;
margin-right: 8px;
}
wc-input {
flex: 1;
}
wc-button {
width: 80px;
}
}
}
.addon-attach {
width: 320px;
padding: 8px 5px;
background: #fff;
font-size: 13px;
.tabs {
display: flex;
border-bottom: 1px solid var(--color-plain-2);
user-select: none;
span {
height: 28px;
padding: 0 8px;
line-height: 28px;
cursor: pointer;
&.active {
color: var(--color-teal-1);
}
}
}
.remote,
.locale {
display: none;
&.active {
display: block;
}
}
.locale {
height: 120px;
padding: 24px 32px;
.button {
position: relative;
width: 100%;
height: 100%;
padding: 12px 16px;
line-height: 46px;
border: 1px dashed var(--color-plain-3);
border-radius: 4px;
text-align: center;
cursor: pointer;
transition: background 0.1s ease-in-out;
&::after {
content: '点击选择文件,或拖拽文件到此处';
}
&:hover,
&.active {
background: rgba(255, 228, 196, 0.15);
}
}
input {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
opacity: 0;
}
}
li {
display: flex;
align-items: center;
padding: 0 12px;
margin-top: 6px;
label {
width: 60px;
margin-right: 8px;
}
wc-input {
flex: 1;
}
wc-button {
width: 80px;
}
}
}
`
]
get value() {
try {
return this.$refs.editor.value
} catch (err) {
console.log(err)
return ''
}
}
set value(val) {
if (this.$refs.editor) {
if (this.$refs.editor.value === val) {
return
}
this.$refs.editor.value = val
this.#updatePreview()
this.$emit('input')
} else {
nextTick(_ => (this.value = val))
}
}
#toolbar = [...DEFAULT_TOOLS]
#value = ''
#cache = { bar: 0, y: 0 }
#gridx = 0
#gridy = 0
#select = null
previewEnabled = false
__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'
}
#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')
}
// 处理图片
#handleFile(ev, file, t = '!') {
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.insert(`${t}[${file.name}](${link})`)
}
}
})
}
#handlePaste(ev) {
let html = ev.clipboardData.getData('text/html')
let txt = ev.clipboardData.getData('text/plain')
let items = ev.clipboardData.items
if (this.readOnly || this.disabled) {
return
}
// 先文件判断, 避免右键单击复制图片时, 当成html处理
if (items && items.length) {
let file = null
for (let it of items) {
file = it.getAsFile()
if (file) {
break
}
}
if (file) {
return this.#handleFile(
null,
file,
file.type.includes('image') ? '!' : ''
)
}
}
if (html) {
this.insert(html2md(html))
} else if (txt) {
this.insert(txt)
}
}
#updatePreview() {
if (this.previewEnabled) {
this.$refs.view.code = this.value
}
}
#toolbarClick(ev) {
var elem = ev.target
var act
this.restoreSelection()
if (elem === ev.currentTarget) {
return
}
if (this.readOnly || this.disabled) {
return
}
while (elem.tagName !== 'SPAN') {
elem = elem.parentNode
}
act = elem.dataset.act
// this.#hideLayers()
Addon[act].call(this, elem)
}
#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)
// 避免每次遍历完所有的节点
let max = Math.max(getIndex(this.#gridx, this.#gridy), idx) + 1
if (x === this.#gridx && y === this.#gridy) {
return
}
this.#gridx = x
this.#gridy = y
for (let i = 0; i < max; 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)
}
}
/**
* 往文本框中插入内容
* @param {String} val [要插入的文本]
* @param {Boolean} isSelect [插入之后是否要选中文本]
* @param {Boolean} offset [选中文本时的偏移量]
*/
insert(val = '', isSelect = false, offset = 0) {
let $el = this.$refs.editor
let start = $el.selectionStart
let end = $el.selectionEnd
let scrollTop = $el.scrollTop
if (start || start === 0) {
$el.value = $el.value.slice(0, start) + val + $el.value.slice(end)
this.select(
(isSelect ? start : start + val.length) + offset,
start + val.length - offset
)
$el.scrollTop = scrollTop
$el.focus()
} else {
$el.value += val
$el.focus()
}
this.#updatePreview()
this.$emit('input')
}
/**
* [selection 获取选中的文本]
* @param {[type]} forceHoleLine [是否强制光标所在的整行文本]
*/
selection(forceHoleLine = false) {
let $el = this.$refs.editor
let start = $el.selectionStart
let end = $el.selectionEnd
if (end) {
//强制选择整行
if (forceHoleLine) {
start = $el.value.slice(0, start).lastIndexOf('\n')
let tmpEnd = $el.value.slice(end).indexOf('\n')
tmpEnd = tmpEnd < 0 ? $el.value.slice(end).length : tmpEnd
start += 1 // 把\n加上
end += tmpEnd
$el.selectionStart = start
$el.selectionEnd = end
}
} else {
//强制选择整行
if (forceHoleLine) {
end = $el.value.indexOf('\n')
end = end < 0 ? $el.value.length : end
$el.selectionEnd = end
}
}
$el.focus()
return $el.value.slice(start, end)
}
select(start = 0, end = 0) {
let $el = this.$refs.editor
$el.selectionStart = start
$el.selectionEnd = end
}
// 设置光标
cursor(pos) {
this.select(pos, pos)
}
#cursorMove(step) {
let $el = this.$refs.editor
let pos = (step < 0 ? $el.selectionStart : $el.selectionEnd) || 0
pos += step
if (step === 0) {
return
}
if (pos < 0) {
pos = 0
}
this.cursor(pos)
}
// 获取光标处的字符
#getCursorText(n) {
let $el = this.$refs.editor
let start = $el.selectionStart
let pos = $el.selectionEnd
if (n < 0) {
pos = start - 1
}
return this.value[pos] || ''
}
#handleKeydown(ev) {
let $el = this.$refs.editor
let wrapTxt = this.selection() || ''
let selected = wrapTxt.length > 0
let newTxt = ''
if (this.readOnly || this.disabled) {
return
}
switch (ev.keyCode) {
//tab键改为插入2个空格,阻止默认事件,防止焦点失去
case 9:
ev.preventDefault()
if (ev.shiftKey && !selected) {
let pos = $el.selectionStart
let line = this.selection(true)
newTxt = line.replace(/^\s{2}/, '')
// 防止无法往左取消缩进时, 选中整行
if (line === newTxt) {
this.cursor(pos)
break
}
pos -= 2
this.insert(newTxt, selected)
this.cursor(pos)
} else {
newTxt = wrapTxt
.split('\n')
.map(function (it) {
return ev.shiftKey ? it.replace(/^\s{2}/, '') : ' ' + it
})
.join('\n')
if (newTxt === wrapTxt) {
break
}
this.insert(newTxt, selected)
}
break
// 退格键, 遇到成对的符号时,同时删除
case 8:
if (!selected) {
let pos = $el.selectionStart
let prev = this.#getCursorText(-1)
let next = this.#getCursorText(1)
if (
(prev === next && (prev === '"' || prev === "'")) ||
(prev === next && prev === '`') ||
(prev === '[' && next === ']') ||
(prev === '{' && next === '}') ||
(prev === '(' && next === ')')
) {
this.select(pos - 1, pos + 1)
}
}
break
// (
case 57:
if (ev.shiftKey) {
ev.preventDefault()
this.insert('(' + wrapTxt + ')', selected, (selected && 1) || 0)
this.#cursorMove(selected ? 0 : -1)
}
break
// )
case 48:
if (ev.shiftKey) {
let prev = this.#getCursorText(-1)
let next = this.#getCursorText(1)
if (prev === '(' && next === ')') {
ev.preventDefault()
this.#cursorMove(1)
}
}
break
case 219: // [ & {
ev.preventDefault()
this.insert(
ev.shiftKey ? `{${wrapTxt}}` : `[${wrapTxt}]`,
selected,
selected ^ 0
)
this.#cursorMove(selected ? 0 : -1)
break
// } & ]
case 221: {
let prev = this.#getCursorText(-1)
let next = this.#getCursorText(1)
if (ev.shiftKey) {
if (prev === '{' && next === '}') {
ev.preventDefault()
this.#cursorMove(1)
}
} else {
if (prev === '[' && next === ']') {
ev.preventDefault()
this.#cursorMove(1)
}
}
break
}
// `
case 192:
// ' & "
case 222:
if (ev.shiftKey && ev.keyCode === 192) {
break
} else {
let val = ev.keyCode === 192 ? '`' : ev.shiftKey ? '"' : "'"
let prev = this.#getCursorText(-1)
let next = this.#getCursorText(1)
if (prev === '' && next === val) {
break
}
ev.preventDefault()
if (selected) {
this.insert(val + wrapTxt + val, true, 1)
} else {
if (prev === next && prev === val) {
this.#cursorMove(1)
} else {
this.insert(val + wrapTxt + val)
this.#cursorMove(-1)
}
}
break
}
}
}
mounted() {
Addon.preview.call(this, this)
}
unmounted() {}
render() {
// console.log(this.#toolbar, this.value)
return html`
<div class="meditor">
<header
class=${classMap({ toolbar: true, active: this.#toolbar.length })}
@click=${this.#toolbarClick}
>
${renderToolbar(this.#toolbar)}
</header>
<div class="editor-outbox">
<textarea
ref="editor"
class="editor"
spellcheck="false"
:readOnly=${this.readOnly}
:disabled=${this.disabled}
@keydown=${this.#handleKeydown}
@paste.prevent=${this.#handlePaste}
@input=${this.#updatePreview}
></textarea>
<wc-markd ref="view" class="preview"></wc-markd>
</div>
</div>
`
}
}
MEditor.reg('meditor')
百搭WCUI组件库, 基于web components开发。面向下一代的UI组件库
JavaScript 98.9%
CSS 1.1%