feat:rich text editor by canvas

This commit is contained in:
黄云飞
2021-11-12 22:22:05 +08:00
commit 2739d4069f
36 changed files with 1251 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
.inputarea {
width: 1px;
height: 12px;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
left: 0;
right: 0;
letter-spacing: 0;
font-size: 12px;
position: absolute;
outline: none;
resize: none;
border: none;
overflow: hidden;
color: transparent;
user-select: none;
background-color: transparent;
}
.cursor {
width: 2px;
height: 20px;
left: 0;
right: 0;
position: absolute;
outline: none;
background-color: #000000;
}
.cursor--animation {
animation-duration: 1s;
animation-iteration-count: infinite;
animation-name: cursorAnimation;
}
@keyframes cursorAnimation {
from {
opacity: 1
}
13% {
opacity: 0
}
50% {
opacity: 0
}
63% {
opacity: 1
}
to {
opacity: 1
}
}
+35
View File
@@ -0,0 +1,35 @@
export class HistoryManager {
private readonly MAX_RECORD_COUNT = 1000
private undoStack: Array<Function> = []
private redoStack: Array<Function> = []
undo() {
if (this.undoStack.length > 1) {
const pop = this.undoStack.pop()!
this.redoStack.push(pop)
if (this.undoStack.length) {
this.undoStack[this.undoStack.length - 1]()
}
}
}
redo() {
if (this.redoStack.length) {
const pop = this.redoStack.pop()!
this.undoStack.push(pop)
pop()
}
}
execute(fn: Function) {
this.undoStack.push(fn)
if (this.redoStack.length) {
this.redoStack = []
}
while (this.undoStack.length > this.MAX_RECORD_COUNT) {
this.undoStack.shift()
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export const ZERO = '\u200B'
export const WRAP = '\n'
+13
View File
@@ -0,0 +1,13 @@
export enum KeyMap {
Backspace = 'Backspace',
Enter = "Enter",
Left = "ArrowLeft",
Right = "ArrowRight",
Up = "ArrowUp",
Down = "ArrowDown",
A = "a",
C = "c",
X = "x",
Y = "y",
Z = "z"
}
+525
View File
@@ -0,0 +1,525 @@
import './assets/css/index.css'
import { ZERO } from './dataset/constant/Common'
import { KeyMap } from './dataset/enum/Keymap'
import { deepClone, writeText } from './utils'
import { HistoryManager } from './core/history/HistoryManager'
import { IRange } from './interface/Range'
import { IRow } from './interface/Row'
import { IDrawOption } from './interface/Draw'
import { IEditorOption } from './interface/Editor'
import { IElement, IElementPosition } from './interface/Element'
export default class Editor {
private readonly defaultOptions: Required<IEditorOption> = {
defaultType: 'TEXT',
defaultFont: 'Yahei',
defaultSize: 16,
rangeAlpha: 0.6,
rangeColor: '#AECBFA',
marginIndicatorSize: 35,
marginIndicatorColor: '#BABABA',
margins: [100, 120, 100, 120]
}
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: Required<IEditorOption>
private elementList: IElement[]
private position: IElementPosition[]
private range: IRange
private cursorPosition: IElementPosition | null
private cursorDom: HTMLDivElement
private textareaDom: HTMLTextAreaElement
private isCompositing: boolean
private isAllowDrag: boolean
private rowCount: number
private mouseDownStartIndex: number
private historyManager: HistoryManager
constructor(canvas: HTMLCanvasElement, data: IElement[], options: IEditorOption = {}) {
this.options = {
...this.defaultOptions,
...options
};
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio;
canvas.width = parseInt(canvas.style.width) * dpr;
canvas.height = parseInt(canvas.style.height) * dpr;
canvas.style.cursor = 'text'
this.canvas = canvas
this.ctx = ctx as CanvasRenderingContext2D
this.ctx.scale(dpr, dpr)
this.elementList = []
this.position = []
this.cursorPosition = null
this.isCompositing = false
this.isAllowDrag = false
this.range = {
startIndex: 0,
endIndex: 0
}
this.rowCount = 0
this.mouseDownStartIndex = 0
// 历史管理
this.historyManager = new HistoryManager()
// 全局事件
document.addEventListener('click', (evt) => {
const innerDoms = [this.canvas, this.cursorDom, this.textareaDom, document.body]
if (innerDoms.includes(evt.target as any)) return
this.recoveryCursor()
})
document.addEventListener('mouseup', () => {
this.isAllowDrag = false
})
// 事件监听转发
const textarea = document.createElement('textarea')
textarea.autocomplete = 'off'
textarea.classList.add('inputarea')
textarea.innerText = ''
textarea.onkeydown = (evt: KeyboardEvent) => this.handleKeydown(evt)
textarea.oninput = (evt: Event) => {
const data = (evt as InputEvent).data
setTimeout(() => this.handleInput(data || ''))
}
textarea.onpaste = (evt: ClipboardEvent) => this.handlePaste(evt)
textarea.addEventListener('compositionstart', this.handleCompositionstart.bind(this))
textarea.addEventListener('compositionend', this.handleCompositionend.bind(this))
this.canvas.parentNode?.append(textarea)
this.textareaDom = textarea
// 光标
this.cursorDom = document.createElement('div')
this.cursorDom.classList.add('cursor')
this.canvas.parentNode?.append(this.cursorDom)
// canvas原生事件
canvas.addEventListener('mousedown', this.setCursor.bind(this))
canvas.addEventListener('mousedown', this.handleMousedown.bind(this))
canvas.addEventListener('mouseleave', this.handleMouseleave.bind(this))
canvas.addEventListener('mousemove', this.handleMousemove.bind(this))
// 启动
const isZeroStart = data[0].value === ZERO
if (!isZeroStart) {
data.unshift({
value: ZERO
})
}
data.forEach(text => {
if (text.value === '\n') {
text.value = ZERO
}
})
this.elementList = data
this.draw()
}
private draw(options?: IDrawOption) {
let { curIndex, isSubmitHistory = true, isSetCursor = true } = options || {}
// 清除光标
this.recoveryCursor()
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.position = []
// 基础信息
const { defaultSize, defaultFont, margins, marginIndicatorColor, marginIndicatorSize } = this.options
const canvasRect = this.canvas.getBoundingClientRect()
const canvasWidth = canvasRect.width
const canvasHeight = canvasRect.height
// 绘制页边距
this.ctx.save()
this.ctx.strokeStyle = marginIndicatorColor
this.ctx.beginPath()
const leftTopPoint: [number, number] = [margins[3], margins[0]]
const rightTopPoint: [number, number] = [canvasWidth - margins[1], margins[0]]
const leftBottomPoint: [number, number] = [margins[3], canvasHeight - margins[2]]
const rightBottomPoint: [number, number] = [canvasWidth - margins[1], canvasHeight - margins[2]]
// 上左
this.ctx.moveTo(leftTopPoint[0] - marginIndicatorSize, leftTopPoint[1])
this.ctx.lineTo(...leftTopPoint)
this.ctx.lineTo(leftTopPoint[0], leftTopPoint[1] - marginIndicatorSize)
// 上右
this.ctx.moveTo(rightTopPoint[0] + marginIndicatorSize, rightTopPoint[1])
this.ctx.lineTo(...rightTopPoint)
this.ctx.lineTo(rightTopPoint[0], rightTopPoint[1] - marginIndicatorSize)
// 下左
this.ctx.moveTo(leftBottomPoint[0] - marginIndicatorSize, leftBottomPoint[1])
this.ctx.lineTo(...leftBottomPoint)
this.ctx.lineTo(leftBottomPoint[0], leftBottomPoint[1] + marginIndicatorSize)
// 下右
this.ctx.moveTo(rightBottomPoint[0] + marginIndicatorSize, rightBottomPoint[1])
this.ctx.lineTo(...rightBottomPoint)
this.ctx.lineTo(rightBottomPoint[0], rightBottomPoint[1] + marginIndicatorSize)
this.ctx.stroke()
this.ctx.restore()
// 计算行信息
const rowList: IRow[] = []
if (this.elementList.length) {
rowList.push({
width: 0,
height: 0,
ascent: 0,
elementList: []
})
}
for (let i = 0; i < this.elementList.length; i++) {
this.ctx.save()
const curRow: IRow = rowList[rowList.length - 1]
const element = this.elementList[i]
this.ctx.font = `${element.bold ? 'bold ' : ''}${element.size || defaultSize}px ${element.font || defaultFont}`
const metrics = this.ctx.measureText(element.value)
const width = metrics.width
const fontBoundingBoxAscent = metrics.fontBoundingBoxAscent
const fontBoundingBoxDescent = metrics.fontBoundingBoxDescent
const height = fontBoundingBoxAscent + fontBoundingBoxDescent
const lineText = { ...element, metrics }
if (curRow.width + width > rightTopPoint[0] - leftTopPoint[0] || (i !== 0 && element.value === ZERO)) {
rowList.push({
width,
height: 0,
elementList: [lineText],
ascent: fontBoundingBoxAscent
})
} else {
curRow.width += width
if (curRow.height < height) {
curRow.height = height
curRow.ascent = fontBoundingBoxAscent
}
curRow.elementList.push(lineText)
}
this.ctx.restore()
}
// 渲染元素
let x = leftTopPoint[0]
let y = leftTopPoint[1]
let index = 0
for (let i = 0; i < rowList.length; i++) {
const curRow = rowList[i];
for (let j = 0; j < curRow.elementList.length; j++) {
this.ctx.save()
const element = curRow.elementList[j];
const metrics = element.metrics
this.ctx.font = `${element.bold ? 'bold ' : ''}${element.size || defaultSize}px ${element.font || defaultFont}`
if (element.color) {
this.ctx.fillStyle = element.color
}
const positionItem: IElementPosition = {
index,
value: element.value,
rowNo: i,
metrics,
lineHeight: curRow.height,
isLastLetter: j === curRow.elementList.length - 1,
coordinate: {
leftTop: [x, y],
leftBottom: [x, y + curRow.height],
rightTop: [x + metrics.width, y],
rightBottom: [x + metrics.width, y + curRow.height]
}
}
this.position.push(positionItem)
this.ctx.fillText(element.value, x, y + curRow.ascent)
// 选区绘制
const { startIndex, endIndex } = this.range
if (startIndex !== endIndex && startIndex < index && index <= endIndex) {
this.ctx.save()
this.ctx.globalAlpha = this.options.rangeAlpha
this.ctx.fillStyle = this.options.rangeColor
this.ctx.fillRect(x, y, metrics.width, curRow.height)
this.ctx.restore()
}
index++
x += metrics.width
this.ctx.restore()
}
x = leftTopPoint[0]
y += curRow.height
}
// 光标重绘
if (curIndex === undefined) {
curIndex = this.position.length - 1
}
if (isSetCursor) {
this.cursorPosition = this.position[curIndex!] || null
this.drawCursor()
}
// canvas高度自适应计算
const lastPosition = this.position[this.position.length - 1]
const { coordinate: { leftBottom, leftTop } } = lastPosition
if (leftBottom[1] > this.canvas.height) {
const height = Math.ceil(leftBottom[1] + (leftBottom[1] - leftTop[1]))
this.canvas.height = height
this.canvas.style.height = `${height}px`
this.draw({ curIndex, isSubmitHistory: false })
}
this.rowCount = rowList.length
// 历史记录用于undo、redo
if (isSubmitHistory) {
const self = this
const oldelementList = deepClone(this.elementList)
this.historyManager.execute(function () {
self.elementList = deepClone(oldelementList)
self.draw({ curIndex, isSubmitHistory: false })
})
}
}
private getCursorPosition(evt: MouseEvent): number {
const x = evt.offsetX
const y = evt.offsetY
let isTextArea = false
for (let j = 0; j < this.position.length; j++) {
const { index, coordinate: { leftTop, rightTop, leftBottom } } = this.position[j];
// 命中元素
if (leftTop[0] <= x && rightTop[0] >= x && leftTop[1] <= y && leftBottom[1] >= y) {
let curPostionIndex = j
// 判断是否元素中间前后
if (this.elementList[index].value !== ZERO) {
const valueWidth = rightTop[0] - leftTop[0]
if (x < leftTop[0] + valueWidth / 2) {
curPostionIndex = j - 1
}
}
isTextArea = true
return curPostionIndex
}
}
// 非命中区域
if (!isTextArea) {
let isLastArea = false
let curPostionIndex = -1
// 判断所属行是否存在元素
const firstLetterList = this.position.filter(p => p.isLastLetter)
for (let j = 0; j < firstLetterList.length; j++) {
const { index, coordinate: { leftTop, leftBottom } } = firstLetterList[j]
if (y > leftTop[1] && y <= leftBottom[1]) {
curPostionIndex = index
isLastArea = true
break
}
}
if (!isLastArea) {
return this.position.length - 1
}
return curPostionIndex
}
return -1
}
private setCursor(evt: MouseEvent) {
const positionIndex = this.getCursorPosition(evt)
if (~positionIndex) {
this.range.startIndex = 0
this.range.endIndex = 0
setTimeout(() => {
this.draw({ curIndex: positionIndex, isSubmitHistory: false })
})
}
}
private drawCursor() {
if (!this.cursorPosition) return
// 设置光标代理
const { lineHeight, metrics, coordinate: { rightTop } } = this.cursorPosition
const height = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent
this.textareaDom.focus()
this.textareaDom.setSelectionRange(0, 0)
const lineBottom = rightTop[1] + lineHeight
const curosrleft = `${rightTop[0]}px`
this.textareaDom.style.left = curosrleft
this.textareaDom.style.top = `${lineBottom - 12}px`
// 模拟光标显示
this.cursorDom.style.left = curosrleft
this.cursorDom.style.top = `${lineBottom - height}px`
this.cursorDom.style.display = 'block'
this.cursorDom.style.height = `${height}px`
setTimeout(() => {
this.cursorDom.classList.add('cursor--animation')
}, 200)
}
private recoveryCursor() {
this.cursorDom.style.display = 'none'
this.cursorDom.classList.remove('cursor--animation')
}
private strokeRange() {
this.draw({
isSubmitHistory: false,
isSetCursor: false
})
}
private clearRange() {
this.range.startIndex = 0
this.range.endIndex = 0
}
private handleMousemove(evt: MouseEvent) {
if (!this.isAllowDrag) return
// 结束位置
const endIndex = this.getCursorPosition(evt)
let end = ~endIndex ? endIndex : 0
// 开始位置
let start = this.mouseDownStartIndex
if (start > end) {
[start, end] = [end, start]
}
this.range.startIndex = start
this.range.endIndex = end
if (start === end) return
// 绘制选区
this.strokeRange()
}
private handleMousedown(evt: MouseEvent) {
this.isAllowDrag = true
this.mouseDownStartIndex = this.getCursorPosition(evt) || 0
}
private handleMouseleave(evt: MouseEvent) {
// 是否还在canvas内部
const { x, y, width, height } = this.canvas.getBoundingClientRect()
if (evt.x >= x && evt.x <= x + width && evt.y >= y && evt.y <= y + height) return
this.isAllowDrag = false
}
private handleKeydown(evt: KeyboardEvent) {
if (!this.cursorPosition) return
const { index } = this.cursorPosition
const { startIndex, endIndex } = this.range
const isCollspace = startIndex === endIndex
if (evt.key === KeyMap.Backspace) {
// 判断是否允许删除
if (this.elementList[index].value === ZERO && index === 0) {
evt.preventDefault()
return
}
if (!isCollspace) {
this.elementList.splice(startIndex + 1, endIndex - startIndex)
} else {
this.elementList.splice(index, 1)
}
this.clearRange()
this.draw({ curIndex: isCollspace ? index - 1 : startIndex })
} else if (evt.key === KeyMap.Enter) {
const enterText: IElement = {
value: ZERO
}
if (isCollspace) {
this.elementList.splice(index + 1, 0, enterText)
} else {
this.elementList.splice(startIndex + 1, endIndex - startIndex, enterText)
}
this.clearRange()
this.draw({ curIndex: index + 1 })
} else if (evt.key === KeyMap.Left) {
if (index > 0) {
this.clearRange()
this.draw({ curIndex: index - 1, isSubmitHistory: false })
}
} else if (evt.key === KeyMap.Right) {
if (index < this.position.length - 1) {
this.clearRange()
this.draw({ curIndex: index + 1, isSubmitHistory: false })
}
} else if (evt.key === KeyMap.Up || evt.key === KeyMap.Down) {
const { rowNo, index, coordinate: { leftTop, rightTop } } = this.cursorPosition
if ((evt.key === KeyMap.Up && rowNo !== 0) || (evt.key === KeyMap.Down && rowNo !== this.rowCount)) {
// 下一个光标点所在行位置集合
const probablePosition = evt.key === KeyMap.Up
? this.position.slice(0, index).filter(p => p.rowNo === rowNo - 1)
: this.position.slice(index, this.position.length - 1).filter(p => p.rowNo === rowNo + 1)
// 查找与当前位置元素点交叉最多的位置
let maxIndex = 0
let maxDistance = 0
for (let p = 0; p < probablePosition.length; p++) {
const position = probablePosition[p]
// 当前光标在前
if (position.coordinate.leftTop[0] >= leftTop[0] && position.coordinate.leftTop[0] <= rightTop[0]) {
const curDistance = rightTop[0] - position.coordinate.leftTop[0]
if (curDistance > maxDistance) {
maxIndex = position.index
maxDistance = curDistance
}
}
// 当前光标在后
else if (position.coordinate.leftTop[0] <= leftTop[0] && position.coordinate.rightTop[0] >= leftTop[0]) {
const curDistance = position.coordinate.rightTop[0] - leftTop[0]
if (curDistance > maxDistance) {
maxIndex = position.index
maxDistance = curDistance
}
}
// 匹配不到
if (p === probablePosition.length - 1 && maxIndex === 0) {
maxIndex = position.index
}
}
this.clearRange()
this.draw({ curIndex: maxIndex, isSubmitHistory: false })
}
} else if (evt.ctrlKey && evt.key === KeyMap.Z) {
this.historyManager.undo()
evt.preventDefault()
} else if (evt.ctrlKey && evt.key === KeyMap.Y) {
this.historyManager.redo()
evt.preventDefault()
} else if (evt.ctrlKey && evt.key === KeyMap.C) {
if (!isCollspace) {
writeText(this.elementList.slice(startIndex + 1, endIndex + 1).map(p => p.value).join(''))
}
} else if (evt.ctrlKey && evt.key === KeyMap.X) {
if (!isCollspace) {
writeText(this.position.slice(startIndex + 1, endIndex + 1).map(p => p.value).join(''))
this.elementList.splice(startIndex + 1, endIndex - startIndex)
this.clearRange()
this.draw({ curIndex: startIndex })
}
} else if (evt.ctrlKey && evt.key === KeyMap.A) {
this.range.startIndex = 0
this.range.endIndex = this.position.length - 1
this.draw({ isSubmitHistory: false, isSetCursor: false })
}
}
private handleInput(data: string) {
if (!data || !this.cursorPosition || this.isCompositing) return
this.textareaDom.value = ''
const { index } = this.cursorPosition
const { startIndex, endIndex } = this.range
const isCollspace = startIndex === endIndex
const inputData: IElement[] = data.split('').map(value => ({
value
}))
if (isCollspace) {
this.elementList.splice(index + 1, 0, ...inputData)
} else {
this.elementList.splice(startIndex + 1, endIndex - startIndex, ...inputData)
}
this.clearRange()
this.draw({ curIndex: (isCollspace ? index : startIndex) + inputData.length })
}
private handlePaste(evt: ClipboardEvent) {
const text = evt.clipboardData?.getData('text')
this.handleInput(text || '')
evt.preventDefault()
}
private handleCompositionstart() {
this.isCompositing = true
}
private handleCompositionend() {
this.isCompositing = false
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface IDrawOption {
curIndex?: number;
isSetCursor?: boolean
isSubmitHistory?: boolean;
}
+10
View File
@@ -0,0 +1,10 @@
export interface IEditorOption {
defaultType?: string;
defaultFont?: string;
defaultSize?: number;
rangeColor?: string;
rangeAlpha?: number;
marginIndicatorSize?: number;
marginIndicatorColor?: string,
margins?: [top: number, right: number, bootom: number, left: number]
}
+28
View File
@@ -0,0 +1,28 @@
export interface IElement {
type?: 'TEXT' | 'IMAGE';
value: string;
font?: string;
size?: number;
width?: number;
height?: number;
bold?: boolean;
color?: string;
italic?: boolean;
underline?: boolean;
strikeout?: boolean;
}
export interface IElementPosition {
index: number;
value: string,
rowNo: number;
lineHeight: number;
metrics: TextMetrics;
isLastLetter: boolean,
coordinate: {
leftTop: number[];
leftBottom: number[];
rightTop: number[];
rightBottom: number[];
}
}
+4
View File
@@ -0,0 +1,4 @@
export interface IRange {
startIndex: number;
endIndex: number
}
+12
View File
@@ -0,0 +1,12 @@
import { IElement } from "./Element";
export type IRowElement = IElement & {
metrics: TextMetrics
}
export interface IRow {
width: number;
height: number;
ascent: number;
elementList: IRowElement[];
}
+32
View File
@@ -0,0 +1,32 @@
import { ZERO } from "../dataset/constant/Common"
export function debounce(func: Function, delay: number) {
let timer: number
return function (...args: any) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
// @ts-ignore
func.apply(this, args)
}, delay)
}
}
export function writeText(text: string) {
if (!text) return
window.navigator.clipboard.writeText(text.replaceAll(ZERO, `\n`))
}
export function deepClone(obj: any) {
if (!obj || typeof obj !== 'object') {
return obj;
}
let newObj: any = {};
if (Array.isArray(obj)) {
newObj = obj.map(item => deepClone(item));
} else {
Object.keys(obj).forEach((key) => {
return newObj[key] = deepClone(obj[key]);
})
}
return newObj;
}