feat:核心层解耦

pr675
黄云飞 4 years ago
parent 2739d4069f
commit 6dae6084df

@ -0,0 +1,22 @@
import { Draw } from "../draw/Draw"
export class Command {
private undo: Function
private redo: Function
constructor(draw: Draw) {
const historyManager = draw.getHistoryManager()
this.undo = historyManager.undo
this.redo = historyManager.redo
}
public executeUndo() {
return this.undo
}
public executeRedo() {
return this.redo
}
}

@ -0,0 +1,74 @@
import { Draw } from "../draw/Draw"
import { CanvasEvent } from "../event/CanvasEvent"
import { Position } from "../position/Position"
import { RangeManager } from "../range/RangeManager"
import { CursorAgent } from "./CursorAgent"
export class Cursor {
private canvas: HTMLCanvasElement
private draw: Draw
private range: RangeManager
private position: Position
private cursorDom: HTMLDivElement
private cursorAgent: CursorAgent
constructor(canvas: HTMLCanvasElement, draw: Draw, canvasEvent: CanvasEvent) {
this.canvas = canvas
this.draw = draw
this.range = this.draw.getRange()
this.position = this.draw.getPosition()
this.cursorDom = document.createElement('div')
this.cursorDom.classList.add('cursor')
this.canvas.parentNode?.append(this.cursorDom)
this.cursorAgent = new CursorAgent(canvas, canvasEvent)
}
public getCursorDom(): HTMLDivElement {
return this.cursorDom
}
public getAgentDom(): HTMLTextAreaElement {
return this.cursorAgent.getAgentCursorDom()
}
public setCursorPosition(evt: MouseEvent) {
const positionIndex = this.position.getPositionByXY(evt.offsetX, evt.offsetY)
if (~positionIndex) {
this.range.setRange(0, 0)
setTimeout(() => {
this.draw.render({ curIndex: positionIndex, isSubmitHistory: false })
})
}
}
public drawCursor() {
const cursorPosition = this.draw.getPosition().getCursorPosition()
if (!cursorPosition) return
// 设置光标代理
const { lineHeight, metrics, coordinate: { rightTop } } = cursorPosition
const height = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent
const agentCursorDom = this.cursorAgent.getAgentCursorDom()
agentCursorDom.focus()
agentCursorDom.setSelectionRange(0, 0)
const lineBottom = rightTop[1] + lineHeight
const curosrleft = `${rightTop[0]}px`
agentCursorDom.style.left = curosrleft
agentCursorDom.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)
}
public recoveryCursor() {
this.cursorDom.style.display = 'none'
this.cursorDom.classList.remove('cursor--animation')
}
}

@ -0,0 +1,53 @@
import { debounce } from "../../utils"
import { CanvasEvent } from "../event/CanvasEvent"
export class CursorAgent {
private canvas: HTMLCanvasElement
private agentCursorDom: HTMLTextAreaElement
private canvasEvent: CanvasEvent
constructor(canvas: HTMLCanvasElement, canvasEvent: CanvasEvent) {
this.canvas = canvas
this.canvasEvent = canvasEvent
// 代理光标绘制
const agentCursorDom = document.createElement('textarea')
agentCursorDom.autocomplete = 'off'
agentCursorDom.classList.add('inputarea')
agentCursorDom.innerText = ''
this.canvas.parentNode?.append(agentCursorDom)
this.agentCursorDom = agentCursorDom
// 事件
agentCursorDom.onkeydown = (evt: KeyboardEvent) => this.keyDown(evt)
agentCursorDom.oninput = debounce(this.input.bind(this), 0)
agentCursorDom.onpaste = (evt: ClipboardEvent) => this.paste(evt)
agentCursorDom.addEventListener('compositionstart', this.compositionstart.bind(this))
agentCursorDom.addEventListener('compositionend', this.compositionend.bind(this))
}
public getAgentCursorDom(): HTMLTextAreaElement {
return this.agentCursorDom
}
keyDown(evt: KeyboardEvent) {
this.canvasEvent.keydown(evt)
}
input(evt: InputEvent) {
if (!evt.data) return
this.canvasEvent.input(evt.data)
}
paste(evt: ClipboardEvent) {
this.canvasEvent.paste(evt)
}
compositionstart() {
this.canvasEvent.compositionstart()
}
compositionend() {
this.canvasEvent.compositionend()
}
}

@ -0,0 +1,199 @@
import { ZERO } from "../../dataset/constant/Common"
import { IDrawOption } from "../../interface/Draw"
import { IEditorOption } from "../../interface/Editor"
import { IElement, IElementPosition } from "../../interface/Element"
import { IRow } from "../../interface/Row"
import { deepClone } from "../../utils"
import { Cursor } from "../cursor/Cursor"
import { CanvasEvent } from "../event/CanvasEvent"
import { GlobalEvent } from "../event/GlobalEvent"
import { HistoryManager } from "../history/HistoryManager"
import { Position } from "../position/Position"
import { RangeManager } from "../range/RangeManager"
import { Margin } from "./Margin"
export class Draw {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private options: Required<IEditorOption>
private position: Position
private elementList: IElement[]
private cursor: Cursor
private range: RangeManager
private margin: Margin
private historyManager: HistoryManager
private rowCount: number
constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, options: Required<IEditorOption>, elementList: IElement[]) {
this.canvas = canvas
this.ctx = ctx
this.options = options
this.elementList = elementList
this.historyManager = new HistoryManager()
this.position = new Position(this)
this.range = new RangeManager(ctx, options)
this.margin = new Margin(ctx, options)
const canvasEvent = new CanvasEvent(canvas, this)
this.cursor = new Cursor(canvas, this, canvasEvent)
canvasEvent.register()
const globalEvent = new GlobalEvent(canvas, this, canvasEvent)
globalEvent.register()
this.rowCount = 0
}
public getHistoryManager(): HistoryManager {
return this.historyManager
}
public getPosition(): Position {
return this.position
}
public getRange(): RangeManager {
return this.range
}
public getElementList(): IElement[] {
return this.elementList
}
public getCursor(): Cursor {
return this.cursor
}
public getRowCount(): number {
return this.rowCount
}
public render(payload?: IDrawOption) {
let { curIndex, isSubmitHistory = true, isSetCursor = true } = payload || {}
// 清除光标
this.cursor.recoveryCursor()
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.position.setPositionList([])
const positionList = this.position.getPositionList()
// 基础信息
const { defaultSize, defaultFont } = this.options
const canvasRect = this.canvas.getBoundingClientRect()
// 绘制页边距
const { width } = canvasRect
const { margins } = this.options
const leftTopPoint: [number, number] = [margins[3], margins[0]]
const rightTopPoint: [number, number] = [width - margins[1], margins[0]]
this.margin.render(canvasRect)
// 计算行信息
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]
}
}
positionList.push(positionItem)
this.ctx.fillText(element.value, x, y + curRow.ascent)
// 选区绘制
const { startIndex, endIndex } = this.range.getRange()
if (startIndex < index && index <= endIndex) {
this.range.drawRange(x, y, metrics.width, curRow.height)
}
index++
x += metrics.width
this.ctx.restore()
}
x = leftTopPoint[0]
y += curRow.height
}
// 光标重绘
if (curIndex === undefined) {
curIndex = positionList.length - 1
}
if (isSetCursor) {
this.position.setCursorPosition(positionList[curIndex!] || null)
this.cursor.drawCursor()
}
// canvas高度自适应计算
const lastPosition = positionList[positionList.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.render({ 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.render({ curIndex, isSubmitHistory: false })
})
}
}
}

@ -0,0 +1,43 @@
import { IEditorOption } from "../../interface/Editor"
export class Margin {
private ctx: CanvasRenderingContext2D
private options: Required<IEditorOption>
constructor(ctx: CanvasRenderingContext2D, options: Required<IEditorOption>) {
this.ctx = ctx
this.options = options
}
render(canvasRect: DOMRect) {
const { width, height } = canvasRect
const { marginIndicatorColor, marginIndicatorSize, margins } = this.options
this.ctx.save()
this.ctx.strokeStyle = marginIndicatorColor
this.ctx.beginPath()
const leftTopPoint: [number, number] = [margins[3], margins[0]]
const rightTopPoint: [number, number] = [width - margins[1], margins[0]]
const leftBottomPoint: [number, number] = [margins[3], height - margins[2]]
const rightBottomPoint: [number, number] = [width - margins[1], height - 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()
}
}

@ -0,0 +1,218 @@
import { ZERO } from "../../dataset/constant/Common"
import { KeyMap } from "../../dataset/enum/Keymap"
import { IElement } from "../../interface/Element"
import { writeText } from "../../utils"
import { Cursor } from "../cursor/Cursor"
import { Draw } from "../draw/Draw"
import { HistoryManager } from "../history/HistoryManager"
import { Position } from "../position/Position"
import { RangeManager } from "../range/RangeManager"
export class CanvasEvent {
private isAllowDrag: boolean
private isCompositing: boolean
private mouseDownStartIndex: number
private draw: Draw
private canvas: HTMLCanvasElement
private position: Position
private range: RangeManager
private cursor: Cursor | null
private historyManager: HistoryManager
constructor(canvas: HTMLCanvasElement, draw: Draw) {
this.isAllowDrag = false
this.isCompositing = false
this.mouseDownStartIndex = 0
this.canvas = canvas
this.draw = draw
this.cursor = null
this.position = this.draw.getPosition()
this.range = this.draw.getRange()
this.historyManager = this.draw.getHistoryManager()
}
public register() {
// 延迟加载
this.cursor = this.draw.getCursor()
this.canvas.addEventListener('mousedown', this.cursor.setCursorPosition.bind(this))
this.canvas.addEventListener('mousedown', this.mousedown.bind(this))
this.canvas.addEventListener('mouseleave', this.mouseleave.bind(this))
this.canvas.addEventListener('mousemove', this.mousemove.bind(this))
}
public setIsAllowDrag(payload: boolean) {
this.isAllowDrag = payload
}
public mousemove(evt: MouseEvent) {
if (!this.isAllowDrag) return
// 结束位置
const endIndex = this.draw.getPosition().getPositionByXY(evt.offsetX, evt.offsetY)
let end = ~endIndex ? endIndex : 0
// 开始位置
let start = this.mouseDownStartIndex
if (start > end) {
[start, end] = [end, start]
}
this.draw.getRange().setRange(start, end)
if (start === end) return
// 绘制
this.draw.render({
isSubmitHistory: false,
isSetCursor: false
})
}
public mousedown(evt: MouseEvent) {
this.isAllowDrag = true
this.mouseDownStartIndex = this.draw.getPosition().getPositionByXY(evt.offsetX, evt.offsetY) || 0
}
public mouseleave(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
}
public keydown(evt: KeyboardEvent) {
const cursorPosition = this.position.getCursorPosition()
if (!cursorPosition) return
const elementList = this.draw.getElementList()
const position = this.position.getPositionList()
const { index } = cursorPosition
const { startIndex, endIndex } = this.range.getRange()
const isCollspace = startIndex === endIndex
if (evt.key === KeyMap.Backspace) {
// 判断是否允许删除
if (elementList[index].value === ZERO && index === 0) {
evt.preventDefault()
return
}
if (!isCollspace) {
elementList.splice(startIndex + 1, endIndex - startIndex)
} else {
elementList.splice(index, 1)
}
this.range.setRange(0, 0)
this.draw.render({ curIndex: isCollspace ? index - 1 : startIndex })
} else if (evt.key === KeyMap.Enter) {
const enterText: IElement = {
value: ZERO
}
if (isCollspace) {
elementList.splice(index + 1, 0, enterText)
} else {
elementList.splice(startIndex + 1, endIndex - startIndex, enterText)
}
this.range.setRange(0, 0)
this.draw.render({ curIndex: index + 1 })
} else if (evt.key === KeyMap.Left) {
if (index > 0) {
this.range.setRange(0, 0)
this.draw.render({ curIndex: index - 1, isSubmitHistory: false })
}
} else if (evt.key === KeyMap.Right) {
if (index < position.length - 1) {
this.range.setRange(0, 0)
this.draw.render({ curIndex: index + 1, isSubmitHistory: false })
}
} else if (evt.key === KeyMap.Up || evt.key === KeyMap.Down) {
const { rowNo, index, coordinate: { leftTop, rightTop } } = cursorPosition
if ((evt.key === KeyMap.Up && rowNo !== 0) || (evt.key === KeyMap.Down && rowNo !== this.draw.getRowCount())) {
// 下一个光标点所在行位置集合
const probablePosition = evt.key === KeyMap.Up
? position.slice(0, index).filter(p => p.rowNo === rowNo - 1)
: position.slice(index, 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.range.setRange(0, 0)
this.draw.render({ 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(elementList.slice(startIndex + 1, endIndex + 1).map(p => p.value).join(''))
}
} else if (evt.ctrlKey && evt.key === KeyMap.X) {
if (!isCollspace) {
writeText(position.slice(startIndex + 1, endIndex + 1).map(p => p.value).join(''))
elementList.splice(startIndex + 1, endIndex - startIndex)
this.range.setRange(0, 0)
this.draw.render({ curIndex: startIndex })
}
} else if (evt.ctrlKey && evt.key === KeyMap.A) {
this.range.setRange(0, position.length - 1)
this.draw.render({ isSubmitHistory: false, isSetCursor: false })
}
}
public input(data: string) {
if (!this.cursor) return
const cursorPosition = this.position.getCursorPosition()
if (!data || !cursorPosition || this.isCompositing) return
const elementList = this.draw.getElementList()
const agentDom = this.cursor.getAgentDom()
agentDom.value = ''
const { index } = cursorPosition
const { startIndex, endIndex } = this.range.getRange()
const isCollspace = startIndex === endIndex
const inputData: IElement[] = data.split('').map(value => ({
value
}))
if (isCollspace) {
elementList.splice(index + 1, 0, ...inputData)
} else {
elementList.splice(startIndex + 1, endIndex - startIndex, ...inputData)
}
this.range.setRange(0, 0)
this.draw.render({ curIndex: (isCollspace ? index : startIndex) + inputData.length })
}
public paste(evt: ClipboardEvent) {
const text = evt.clipboardData?.getData('text')
this.input(text || '')
evt.preventDefault()
}
public compositionstart() {
this.isCompositing = true
}
public compositionend() {
this.isCompositing = false
}
}

@ -0,0 +1,44 @@
import { Cursor } from "../cursor/Cursor"
import { Draw } from "../draw/Draw"
import { CanvasEvent } from "./CanvasEvent"
export class GlobalEvent {
private canvas: HTMLCanvasElement
private draw: Draw
private cursor: Cursor | null
private canvasEvent: CanvasEvent
constructor(canvas: HTMLCanvasElement, draw: Draw, canvasEvent: CanvasEvent) {
this.canvas = canvas
this.draw = draw
this.canvasEvent = canvasEvent
this.cursor = null
}
register() {
this.cursor = this.draw.getCursor()
document.addEventListener('click', (evt) => {
this.recoverCursor(evt)
})
document.addEventListener('mouseup', () => {
this.updateDragState()
})
}
recoverCursor(evt: MouseEvent) {
if (!this.cursor) return
const cursorDom = this.cursor.getCursorDom()
const agentDom = this.cursor.getAgentDom()
const innerDoms = [this.canvas, cursorDom, agentDom, this.canvas.parentNode, document.body]
if (innerDoms.includes(evt.target as any)) return
this.cursor.recoveryCursor()
}
updateDragState() {
this.canvasEvent.setIsAllowDrag(false)
}
}

@ -0,0 +1,78 @@
import { ZERO } from "../../dataset/constant/Common"
import { IElement, IElementPosition } from "../../interface/Element"
import { Draw } from "../draw/Draw"
export class Position {
private cursorPosition: IElementPosition | null
private positionList: IElementPosition[]
private elementList: IElement[]
private draw: Draw
constructor(draw: Draw) {
this.positionList = []
this.elementList = []
this.cursorPosition = null
this.draw = draw
}
public getPositionList(): IElementPosition[] {
return this.positionList
}
public setPositionList(payload: IElementPosition[]) {
this.positionList = payload
}
public setCursorPosition(position: IElementPosition) {
this.cursorPosition = position
}
public getCursorPosition(): IElementPosition | null {
return this.cursorPosition
}
public getPositionByXY(x: number, y: number): number {
this.elementList = this.draw.getElementList()
let isTextArea = false
for (let j = 0; j < this.positionList.length; j++) {
const { index, coordinate: { leftTop, rightTop, leftBottom } } = this.positionList[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.positionList.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.positionList.length - 1
}
return curPostionIndex
}
return -1
}
}

@ -0,0 +1,39 @@
import { IEditorOption } from "../../interface/Editor"
import { IRange } from "../../interface/Range"
export class RangeManager {
private ctx: CanvasRenderingContext2D
private options: Required<IEditorOption>
private range: IRange
constructor(ctx: CanvasRenderingContext2D, options: Required<IEditorOption>,) {
this.ctx = ctx
this.options = options
this.range = {
startIndex: 0,
endIndex: 0
}
}
public getRange(): IRange {
return this.range
}
public setRange(startIndex: number, endIndex: number) {
this.range.startIndex = startIndex
this.range.endIndex = endIndex
}
public drawRange(x: number, y: number, width: number, height: number) {
const { startIndex, endIndex } = this.range
if (startIndex !== endIndex) {
this.ctx.save()
this.ctx.globalAlpha = this.options.rangeAlpha
this.ctx.fillStyle = this.options.rangeColor
this.ctx.fillRect(x, y, width, height)
this.ctx.restore()
}
}
}

@ -1,525 +1,47 @@
import './assets/css/index.css' import './assets/css/index.css'
import { ZERO } from './dataset/constant/Common' 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 { IEditorOption } from './interface/Editor'
import { IElement, IElementPosition } from './interface/Element' import { IElement } from './interface/Element'
import { Draw } from './core/draw/Draw'
import { Command } from './core/command/Command'
export default class Editor { export default class Editor {
private readonly defaultOptions: Required<IEditorOption> = { public command: Command
defaultType: 'TEXT',
defaultFont: 'Yahei', constructor(canvas: HTMLCanvasElement, elementList: IElement[], options: IEditorOption = {}) {
defaultSize: 16, const editorOptions: Required<IEditorOption> = {
rangeAlpha: 0.6, defaultType: 'TEXT',
rangeColor: '#AECBFA', defaultFont: 'Yahei',
marginIndicatorSize: 35, defaultSize: 16,
marginIndicatorColor: '#BABABA', rangeAlpha: 0.6,
margins: [100, 120, 100, 120] rangeColor: '#AECBFA',
} marginIndicatorSize: 35,
marginIndicatorColor: '#BABABA',
private canvas: HTMLCanvasElement margins: [100, 120, 100, 120],
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 ...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 const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
this.mouseDownStartIndex = 0 const dpr = window.devicePixelRatio
canvas.width = parseInt(canvas.style.width) * dpr
// 历史管理 canvas.height = parseInt(canvas.style.height) * dpr
this.historyManager = new HistoryManager() canvas.style.cursor = 'text'
ctx.scale(dpr, dpr)
// 全局事件 if (elementList[0].value !== ZERO) {
document.addEventListener('click', (evt) => { elementList.unshift({
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 value: ZERO
}) })
} }
data.forEach(text => { elementList.forEach(text => {
if (text.value === '\n') { if (text.value === '\n') {
text.value = ZERO text.value = ZERO
} }
}) })
this.elementList = data // 启动
this.draw() const draw = new Draw(canvas, ctx, editorOptions, elementList)
} draw.render()
// 对外命令
private draw(options?: IDrawOption) { this.command = new Command(draw)
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
} }
} }

@ -1,4 +1,4 @@
export interface IRange { export interface IRange {
startIndex: number; startIndex: number;
endIndex: number endIndex: number;
} }

@ -1,4 +1,4 @@
import { IElement } from "./Element"; import { IElement } from "./Element"
export type IRowElement = IElement & { export type IRowElement = IElement & {
metrics: TextMetrics metrics: TextMetrics

@ -18,15 +18,15 @@ export function writeText(text: string) {
export function deepClone(obj: any) { export function deepClone(obj: any) {
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return obj; return obj
} }
let newObj: any = {}; let newObj: any = {}
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
newObj = obj.map(item => deepClone(item)); newObj = obj.map(item => deepClone(item))
} else { } else {
Object.keys(obj).forEach((key) => { Object.keys(obj).forEach((key) => {
return newObj[key] = deepClone(obj[key]); return newObj[key] = deepClone(obj[key])
}) })
} }
return newObj; return newObj
} }

@ -42,5 +42,5 @@ window.onload = function () {
const instance = new Editor(canvas, data, { const instance = new Editor(canvas, data, {
margins: [120, 120, 200, 120] margins: [120, 120, 200, 120]
}) })
console.log('编辑器实例: ', instance); console.log('编辑器实例: ', instance)
} }
Loading…
Cancel
Save