完成markdown编辑器的重构

master
yutent 2023-10-16 15:06:24 +08:00
parent 5dec1ebdb7
commit b52c95f210
4 changed files with 126 additions and 415 deletions

View File

@ -6,14 +6,11 @@
import {
css,
raw,
html,
Component,
bind,
unbind,
range,
nextTick,
styleMap,
classMap,
outsideClick,
clearOutsideClick
} from 'wkit'
@ -201,49 +198,24 @@ class Editor extends Component {
}
}
.scroll-outerbox {
.wrapper {
overflow: hidden;
position: relative;
flex: 1;
padding: 5px 8px;
}
.scroll-innerbox {
overflow-y: auto;
width: 100%;
height: 100%;
scrollbar-width: 0;
flex: 1;
padding: 6px 12px;
&::-webkit-scrollbar {
display: none;
}
.wrapper {
min-height: 100%;
}
}
.is-vertical {
position: absolute;
right: 0;
top: 0;
display: flex;
justify-content: flex-end;
width: 10px;
height: 100%;
.thumb {
display: block;
width: 6px;
height: 0;
border-radius: 5px;
background: rgba(44, 47, 53, 0.25);
cursor: default;
transition: width 0.1s linear;
&:hover {
width: 10px;
background: rgba(44, 47, 53, 0.5);
}
&::-webkit-scrollbar-thumb {
visibility: hidden;
border-radius: 3px;
}
&:hover::-webkit-scrollbar-thumb {
visibility: visible;
background: rgba(0, 0, 0, 0.3);
}
}
@ -251,6 +223,7 @@ class Editor extends Component {
width: 100%;
height: 100%;
min-height: 54px;
line-height: 1.5;
outline: none;
text-wrap: wrap;
word-break: break-all;
@ -397,7 +370,6 @@ class Editor extends Component {
]
#value = ''
#cache = { bar: 0, y: 0 }
#gridx = 0
#gridy = 0
@ -419,33 +391,12 @@ class Editor extends Component {
if (this.$refs.editor) {
this.#value = val
this.$refs.editor.innerHTML = val
this.$emit('input')
} 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) {
@ -458,23 +409,6 @@ class Editor extends Component {
}
}
#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')
@ -707,83 +641,11 @@ class Editor extends Component {
}
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)
}
@ -809,8 +671,7 @@ class Editor extends Component {
`
)}
</section>
<div class="scroll-outerbox" ref="outer">
<div class="scroll-innerbox" ref="inner">
<div class="wrapper" ref="cont" @click=${this.#hideLayers}>
<div
ref="editor"
@ -820,11 +681,7 @@ class Editor extends Component {
@paste.prevent=${this.#handlePaste}
></div>
</div>
</div>
<div class="is-vertical noselect">
<span class="thumb" ref="thumb"></span>
</div>
</div>
<div
class="font-layer noselect"
ref="font"
@ -836,6 +693,7 @@ class Editor extends Component {
<span data-size="3">3号字体</span>
<span data-size="2">2号字体</span>
</div>
<div
class="color-layer noselect"
ref="color"
@ -843,6 +701,7 @@ class Editor extends Component {
>
${COLORS.map(c => html`<span data-value=${c}></span>`)}
</div>
<div
class="table-layer noselect"
ref="table"
@ -850,12 +709,11 @@ class Editor extends Component {
@mousemove=${this.#tableSelect}
@mouseleave=${this.#tableSelect}
>
${Array(81)
.fill(0)
.map((_, n) => html`<span data-idx=${n}></span>`)}
${range(81).map((_, n) => html`<span data-idx=${n}></span>`)}
</div>
<div class="link-layer noselect" ref="link">
<wc-input ref="linkinput" label="请输入链接地址"></wc-input>
<wc-input ref="linkinput" placeholder="请输入链接地址"></wc-input>
<wc-button size="m" @click=${this.#insertLink}>插入</wc-button>
</div>
</div>

View File

@ -4,134 +4,94 @@
* @date 2020/10/14 17:52:44
*/
import { offset } from 'wkit'
var placeholder = '在此输入文本'
const PLACEHOLDER = '在此输入文本'
function trim(str, sign) {
return str.replace(new RegExp('^' + sign + '|' + sign + '$', 'g'), '')
}
function docScroll(k = 'X') {
return window[`page${k.toUpperCase()}Offset`]
}
// 通用的弹层触发
function showDialog(dialog, elem) {
var { left, top } = offset(elem)
left -= docScroll('X')
top += 29 - docScroll('Y')
left += 'px'
top += 'px'
dialog.moveTo({ top, left })
dialog.show()
return Promise.resolve(dialog)
}
export default {
header(elem) {
header() {
this.$refs.header.classList.add('fadein')
},
h(level) {
var wrap = this.selection(true) || placeholder
var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(#+ )?/, '#'.repeat(level) + ' ')
this.insert(wrap, true)
},
quote(elem) {
var wrap = this.selection(true) || placeholder
quote() {
var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(>+ )?/, '> ')
this.insert(wrap, true)
},
bold(elem) {
var wrap = this.selection() || placeholder
bold() {
var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '\\*\\*')
wrap = wrap === unwrap ? `**${wrap}**` : unwrap
this.insert(wrap, true)
},
italic(elem) {
var wrap = this.selection() || placeholder
italic() {
var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '_')
wrap = wrap === unwrap ? `_${wrap}_` : unwrap
this.insert(wrap, true)
},
through(elem) {
var wrap = this.selection() || placeholder
through() {
var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '~~')
wrap = wrap === unwrap ? `~~${wrap}~~` : unwrap
this.insert(wrap, true)
},
list(elem) {
var wrap = this.selection(true) || placeholder
list() {
var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^([+\-*] )?/, '+ ')
this.insert(wrap, true)
},
order(elem) {
var wrap = this.selection(true) || placeholder
order() {
var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(\d+\. )?/, '1. ')
this.insert(wrap, true)
},
line(elem) {
line() {
this.insert('\n\n---\n\n', false)
},
code(elem) {
var wrap = this.selection() || placeholder
code() {
var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '`')
wrap = wrap === unwrap ? `\`${wrap}\`` : unwrap
this.insert(wrap, true)
},
codeblock(elem) {
codeblock() {
this.insert('\n```language\n\n```\n')
},
table(elem) {
// showDialog(this.__TABLE_ADDON__, elem)
table() {
this.$refs.table.classList.add('fadein')
},
link(elem) {
showDialog(this.__LINK_ADDON__, elem).then(dialog => {
var wrap = this.selection() || placeholder
dialog.__txt__.value = wrap
})
link() {
this.$refs.link.classList.add('fadein')
},
image(elem) {
var $file = this.__ATTACH_ADDON__.querySelector('input')
this._attach = 'image'
$file.setAttribute('accept', 'image/*')
showDialog(this.__ATTACH_ADDON__, elem)
},
attach(elem) {
var $file = this.__ATTACH_ADDON__.querySelector('input')
this._attach = 'file'
$file.removeAttribute('accept')
showDialog(this.__ATTACH_ADDON__, elem)
},
fullscreen(elem) {
fullscreen() {
this.classList.toggle('fullscreen')
elem.classList.toggle('active')
},
preview(elem) {
preview() {
this.previewEnabled = !this.previewEnabled
this.$refs.view.classList.toggle('active')
elem.classList.toggle('active')
if (this.previewEnabled) {
this.$refs.view.code = this.value
}

View File

@ -4,9 +4,6 @@
* @date 2020/10/12 18:23:23
*/
import { html } from 'wkit'
import ICONS from './svg.js'
const ELEMS = {
a: function (str, attr, inner) {
let href = attr.match(attrExp('href'))
@ -83,31 +80,6 @@ export const DEFAULT_TOOLS = [
'preview'
]
export const TOOL_TITLE = {
header: '插入标题',
h1: '一级标题',
h2: '二级标题',
h3: '三级标题',
h4: '四级标题',
h5: '五级标题',
h6: '六级标题',
quote: '引用文本',
bold: '粗体',
italic: '斜体',
through: '横线',
list: '无序列表',
order: '有序列表',
line: '分割线',
code: '行内代码',
codeblock: '插入代码块',
table: '插入表格',
link: '插入连接',
image: '上传图片',
attach: '上传附件',
fullscreen: '全屏编辑',
preview: '预览'
}
const LI_EXP = /<(ul|ol)>(?:(?!<ul|<ol)[\s\S])*?<\/\1>/gi
// html标签的属性正则
@ -126,21 +98,6 @@ function tagExp(tag, open) {
return new RegExp(exp, 'gi')
}
/**
* 渲染工具栏图标
*/
export function renderToolbar(list = [], dict = {}, showText = false) {
return list.map(it => {
let title = showText ? '' : dict[it]
let text = showText ? dict[it] || '' : ''
return html`<span data-act=${it} title=${title}>
<svg class="icon" viewBox="0 0 1024 1024"><path d=${ICONS[it]} /></svg>
${text}
</span>`
})
}
/**
* html转成md
*/

View File

@ -9,6 +9,7 @@ import {
raw,
html,
Component,
range,
bind,
unbind,
nextTick,
@ -18,7 +19,7 @@ import {
clearOutsideClick
} from 'wkit'
import { renderToolbar, DEFAULT_TOOLS, html2md } from './helper.js'
import { DEFAULT_TOOLS, html2md } from './helper.js'
import Addon from './addon.js'
import markd from '../markd/index.js'
@ -26,6 +27,8 @@ import '../form/input.js'
import '../form/button.js'
import '../code/index.js'
import ICONS from './svg.js'
const COLORS = [
'#f3f5fb',
'#dae1e9',
@ -83,6 +86,7 @@ class MEditor extends Component {
display: flex;
min-width: 200px;
max-height: 720px;
min-height: 64px;
border-radius: 3px;
transition: box-shadow 0.15s linear;
background: var(--wc-meditor-background, #fff);
@ -135,6 +139,12 @@ class MEditor extends Component {
fill: currentColor;
color: #62778d;
}
input {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
}
&:hover,
&.active {
@ -155,7 +165,6 @@ class MEditor extends Component {
flex: 1;
display: flex;
width: 100%;
min-height: 200px;
border-radius: 3px;
.editor,
@ -197,17 +206,13 @@ class MEditor extends Component {
`,
css`
:host([readonly]) {
.editor {
cursor: default;
opacity: 0.8;
}
}
:host([disabled]) {
.editor {
cursor: not-allowed;
opacity: 0.6;
}
}
:host([readonly]),
:host([disabled]) {
.toolbar {
@ -235,6 +240,7 @@ class MEditor extends Component {
css`
.font-layer,
.link-layer,
.table-layer {
visibility: hidden;
position: absolute;
@ -310,119 +316,16 @@ class MEditor extends Component {
}
}
.addon-link {
width: 320px;
padding: 8px 5px;
background: #fff;
font-size: 13px;
li {
.link-layer {
display: flex;
align-items: center;
padding: 0 12px;
margin-top: 6px;
label {
width: 60px;
margin-right: 8px;
}
wc-input {
flex: 1;
}
flex-direction: column;
left: 330px;
width: 230px;
padding: 8px;
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;
}
width: 40px;
margin-top: 8px;
}
}
`,
@ -481,7 +384,7 @@ class MEditor extends Component {
#hideLayers() {
this.$refs.header.classList.remove('fadein')
// this.$refs.color.classList.remove('fadein')
// this.$refs.link.classList.remove('fadein')
this.$refs.link.classList.remove('fadein')
this.$refs.table.classList.remove('fadein')
}
@ -490,6 +393,7 @@ class MEditor extends Component {
if (ev && ev.type === 'change') {
file = ev.target.files[0]
ev.target.value = ''
t = ev.target.dataset.type === 'image' ? '!' : ''
}
this.$emit('upload', {
detail: {
@ -564,6 +468,10 @@ class MEditor extends Component {
this.#hideLayers()
if (act === 'image' || act === 'attach') {
return
}
Addon[act].call(this, elem)
}
@ -582,9 +490,7 @@ class MEditor extends Component {
if (value) {
this.$refs.link.classList.remove('fadein')
this.$refs.editor.focus()
this.restoreSelection()
this.exec(ACTTION.link, value)
this.saveSelection()
this.insert(`[${value}](${value}) `)
this.$refs.linkinput.value = ''
}
}
@ -889,14 +795,25 @@ class MEditor extends Component {
}
}
mounted() {
#fixedPadding() {
if (this.clientHeight > 64) {
let pb = ~~(this.clientHeight * 0.6)
pb = pb < 64 ? 64 : pb
Addon.preview.call(this, this)
this.$refs.editor.style.paddingBottom = pb + 'px'
} else {
this.$refs.editor.style.cssText = 'padding-bottom:;'
}
}
unmounted() {}
mounted() {
this.#fixedPadding()
this.__observer = new ResizeObserver(this.#fixedPadding.bind(this))
this.__observer.observe(this)
}
unmounted() {
this.__observer?.disconnect()
}
render() {
return html`
@ -905,7 +822,22 @@ class MEditor extends Component {
class=${classMap({ toolbar: true, active: this.#toolbar.length })}
@click=${this.#toolbarClick}
>
${renderToolbar(this.#toolbar)}
${this.#toolbar.map(it => {
return html`<span data-act=${it}>
<svg class="icon" viewBox="0 0 1024 1024">
<path d=${ICONS[it]} />
</svg>
${it === 'image' || it === 'attach'
? html`<input
type="file"
data-type=${it}
accept=${it === 'image' ? 'image/*' : '*/*'}
:disabled=${this.readOnly || this.disabled}
@change=${this.#handleFile}
/>`
: ''}
</span>`
})}
</header>
<div class="editor-outbox">
<textarea
@ -919,6 +851,7 @@ class MEditor extends Component {
@input=${this.#updatePreview}
@scroll=${this.#syncScrollToPreview}
></textarea>
<wc-markd ref="view" class="preview"></wc-markd>
</div>
@ -942,9 +875,12 @@ class MEditor extends Component {
@mousemove=${this.#tableSelect}
@mouseleave=${this.#tableSelect}
>
${Array(81)
.fill(0)
.map((_, n) => html`<span data-idx=${n}></span>`)}
${range(81).map((_, n) => html`<span data-idx=${n}></span>`)}
</div>
<div class="link-layer noselect" ref="link">
<wc-input ref="linkinput" placeholder="请输入链接地址"></wc-input>
<wc-button size="m" @click=${this.#insertLink}>插入</wc-button>
</div>
</div>
`