完成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 { import {
css, css,
raw,
html, html,
Component, Component,
bind, bind,
unbind, range,
nextTick, nextTick,
styleMap,
classMap,
outsideClick, outsideClick,
clearOutsideClick clearOutsideClick
} from 'wkit' } from 'wkit'
@ -201,49 +198,24 @@ class Editor extends Component {
} }
} }
.scroll-outerbox { .wrapper {
overflow: hidden; overflow: hidden;
position: relative;
flex: 1;
padding: 5px 8px;
}
.scroll-innerbox {
overflow-y: auto; overflow-y: auto;
width: 100%; flex: 1;
height: 100%; padding: 6px 12px;
scrollbar-width: 0;
&::-webkit-scrollbar { &::-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; 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%; width: 100%;
height: 100%; height: 100%;
min-height: 54px; min-height: 54px;
line-height: 1.5;
outline: none; outline: none;
text-wrap: wrap; text-wrap: wrap;
word-break: break-all; word-break: break-all;
@ -397,7 +370,6 @@ class Editor extends Component {
] ]
#value = '' #value = ''
#cache = { bar: 0, y: 0 }
#gridx = 0 #gridx = 0
#gridy = 0 #gridy = 0
@ -419,33 +391,12 @@ class Editor extends Component {
if (this.$refs.editor) { if (this.$refs.editor) {
this.#value = val this.#value = val
this.$refs.editor.innerHTML = val this.$refs.editor.innerHTML = val
this.$emit('input')
} else { } else {
nextTick(_ => (this.value = val)) 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() { #updateStat() {
if (this.$refs.editor) { if (this.$refs.editor) {
if (this.readOnly || this.disabled) { 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() { #hideLayers() {
this.$refs.font.classList.remove('fadein') this.$refs.font.classList.remove('fadein')
this.$refs.color.classList.remove('fadein') this.$refs.color.classList.remove('fadein')
@ -707,83 +641,11 @@ class Editor extends Component {
} }
mounted() { 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) 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._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() { unmounted() {
this._scrollFn?.disconnect()
this._inputFn?.disconnect()
clearOutsideClick(this._clickoutsideFn) clearOutsideClick(this._clickoutsideFn)
} }
@ -809,8 +671,7 @@ class Editor extends Component {
` `
)} )}
</section> </section>
<div class="scroll-outerbox" ref="outer">
<div class="scroll-innerbox" ref="inner">
<div class="wrapper" ref="cont" @click=${this.#hideLayers}> <div class="wrapper" ref="cont" @click=${this.#hideLayers}>
<div <div
ref="editor" ref="editor"
@ -820,11 +681,7 @@ class Editor extends Component {
@paste.prevent=${this.#handlePaste} @paste.prevent=${this.#handlePaste}
></div> ></div>
</div> </div>
</div>
<div class="is-vertical noselect">
<span class="thumb" ref="thumb"></span>
</div>
</div>
<div <div
class="font-layer noselect" class="font-layer noselect"
ref="font" ref="font"
@ -836,6 +693,7 @@ class Editor extends Component {
<span data-size="3">3号字体</span> <span data-size="3">3号字体</span>
<span data-size="2">2号字体</span> <span data-size="2">2号字体</span>
</div> </div>
<div <div
class="color-layer noselect" class="color-layer noselect"
ref="color" ref="color"
@ -843,6 +701,7 @@ class Editor extends Component {
> >
${COLORS.map(c => html`<span data-value=${c}></span>`)} ${COLORS.map(c => html`<span data-value=${c}></span>`)}
</div> </div>
<div <div
class="table-layer noselect" class="table-layer noselect"
ref="table" ref="table"
@ -850,12 +709,11 @@ class Editor extends Component {
@mousemove=${this.#tableSelect} @mousemove=${this.#tableSelect}
@mouseleave=${this.#tableSelect} @mouseleave=${this.#tableSelect}
> >
${Array(81) ${range(81).map((_, n) => html`<span data-idx=${n}></span>`)}
.fill(0)
.map((_, n) => html`<span data-idx=${n}></span>`)}
</div> </div>
<div class="link-layer noselect" ref="link"> <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> <wc-button size="m" @click=${this.#insertLink}>插入</wc-button>
</div> </div>
</div> </div>

View File

@ -4,134 +4,94 @@
* @date 2020/10/14 17:52:44 * @date 2020/10/14 17:52:44
*/ */
import { offset } from 'wkit' const PLACEHOLDER = '在此输入文本'
var placeholder = '在此输入文本'
function trim(str, sign) { function trim(str, sign) {
return str.replace(new RegExp('^' + sign + '|' + sign + '$', 'g'), '') 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 { export default {
header(elem) { header() {
this.$refs.header.classList.add('fadein') this.$refs.header.classList.add('fadein')
}, },
h(level) { h(level) {
var wrap = this.selection(true) || placeholder var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(#+ )?/, '#'.repeat(level) + ' ') wrap = wrap.replace(/^(#+ )?/, '#'.repeat(level) + ' ')
this.insert(wrap, true) this.insert(wrap, true)
}, },
quote(elem) { quote() {
var wrap = this.selection(true) || placeholder var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(>+ )?/, '> ') wrap = wrap.replace(/^(>+ )?/, '> ')
this.insert(wrap, true) this.insert(wrap, true)
}, },
bold(elem) { bold() {
var wrap = this.selection() || placeholder var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '\\*\\*') var unwrap = trim(wrap, '\\*\\*')
wrap = wrap === unwrap ? `**${wrap}**` : unwrap wrap = wrap === unwrap ? `**${wrap}**` : unwrap
this.insert(wrap, true) this.insert(wrap, true)
}, },
italic(elem) { italic() {
var wrap = this.selection() || placeholder var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '_') var unwrap = trim(wrap, '_')
wrap = wrap === unwrap ? `_${wrap}_` : unwrap wrap = wrap === unwrap ? `_${wrap}_` : unwrap
this.insert(wrap, true) this.insert(wrap, true)
}, },
through(elem) { through() {
var wrap = this.selection() || placeholder var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '~~') var unwrap = trim(wrap, '~~')
wrap = wrap === unwrap ? `~~${wrap}~~` : unwrap wrap = wrap === unwrap ? `~~${wrap}~~` : unwrap
this.insert(wrap, true) this.insert(wrap, true)
}, },
list(elem) { list() {
var wrap = this.selection(true) || placeholder var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^([+\-*] )?/, '+ ') wrap = wrap.replace(/^([+\-*] )?/, '+ ')
this.insert(wrap, true) this.insert(wrap, true)
}, },
order(elem) { order() {
var wrap = this.selection(true) || placeholder var wrap = this.selection(true) || PLACEHOLDER
wrap = wrap.replace(/^(\d+\. )?/, '1. ') wrap = wrap.replace(/^(\d+\. )?/, '1. ')
this.insert(wrap, true) this.insert(wrap, true)
}, },
line(elem) { line() {
this.insert('\n\n---\n\n', false) this.insert('\n\n---\n\n', false)
}, },
code(elem) { code() {
var wrap = this.selection() || placeholder var wrap = this.selection() || PLACEHOLDER
var unwrap = trim(wrap, '`') var unwrap = trim(wrap, '`')
wrap = wrap === unwrap ? `\`${wrap}\`` : unwrap wrap = wrap === unwrap ? `\`${wrap}\`` : unwrap
this.insert(wrap, true) this.insert(wrap, true)
}, },
codeblock(elem) { codeblock() {
this.insert('\n```language\n\n```\n') this.insert('\n```language\n\n```\n')
}, },
table(elem) { table() {
// showDialog(this.__TABLE_ADDON__, elem)
this.$refs.table.classList.add('fadein') this.$refs.table.classList.add('fadein')
}, },
link(elem) { link() {
showDialog(this.__LINK_ADDON__, elem).then(dialog => { this.$refs.link.classList.add('fadein')
var wrap = this.selection() || placeholder
dialog.__txt__.value = wrap
})
}, },
image(elem) { fullscreen() {
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) {
this.classList.toggle('fullscreen') this.classList.toggle('fullscreen')
elem.classList.toggle('active')
}, },
preview(elem) { preview() {
this.previewEnabled = !this.previewEnabled this.previewEnabled = !this.previewEnabled
this.$refs.view.classList.toggle('active') this.$refs.view.classList.toggle('active')
elem.classList.toggle('active')
if (this.previewEnabled) { if (this.previewEnabled) {
this.$refs.view.code = this.value this.$refs.view.code = this.value
} }

View File

@ -4,9 +4,6 @@
* @date 2020/10/12 18:23:23 * @date 2020/10/12 18:23:23
*/ */
import { html } from 'wkit'
import ICONS from './svg.js'
const ELEMS = { const ELEMS = {
a: function (str, attr, inner) { a: function (str, attr, inner) {
let href = attr.match(attrExp('href')) let href = attr.match(attrExp('href'))
@ -83,31 +80,6 @@ export const DEFAULT_TOOLS = [
'preview' '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 const LI_EXP = /<(ul|ol)>(?:(?!<ul|<ol)[\s\S])*?<\/\1>/gi
// html标签的属性正则 // html标签的属性正则
@ -126,21 +98,6 @@ function tagExp(tag, open) {
return new RegExp(exp, 'gi') 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 * html转成md
*/ */

View File

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