diff --git a/src/editor/core/command/Command.ts b/src/editor/core/command/Command.ts new file mode 100644 index 0000000..17f47f5 --- /dev/null +++ b/src/editor/core/command/Command.ts @@ -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 + } + +} \ No newline at end of file diff --git a/src/editor/core/cursor/Cursor.ts b/src/editor/core/cursor/Cursor.ts new file mode 100644 index 0000000..c9859a0 --- /dev/null +++ b/src/editor/core/cursor/Cursor.ts @@ -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') + } + +} \ No newline at end of file diff --git a/src/editor/core/cursor/CursorAgent.ts b/src/editor/core/cursor/CursorAgent.ts new file mode 100644 index 0000000..7fcb2d2 --- /dev/null +++ b/src/editor/core/cursor/CursorAgent.ts @@ -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() + } + +} \ No newline at end of file diff --git a/src/editor/core/draw/Draw.ts b/src/editor/core/draw/Draw.ts new file mode 100644 index 0000000..04584ab --- /dev/null +++ b/src/editor/core/draw/Draw.ts @@ -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 + 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, 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 }) + }) + } + } + +} \ No newline at end of file diff --git a/src/editor/core/draw/Margin.ts b/src/editor/core/draw/Margin.ts new file mode 100644 index 0000000..7652914 --- /dev/null +++ b/src/editor/core/draw/Margin.ts @@ -0,0 +1,43 @@ +import { IEditorOption } from "../../interface/Editor" + +export class Margin { + + private ctx: CanvasRenderingContext2D + private options: Required + + constructor(ctx: CanvasRenderingContext2D, options: Required) { + 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() + } + +} \ No newline at end of file diff --git a/src/editor/core/event/CanvasEvent.ts b/src/editor/core/event/CanvasEvent.ts new file mode 100644 index 0000000..24ec3be --- /dev/null +++ b/src/editor/core/event/CanvasEvent.ts @@ -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 + } + +} \ No newline at end of file diff --git a/src/editor/core/event/GlobalEvent.ts b/src/editor/core/event/GlobalEvent.ts new file mode 100644 index 0000000..a9e9276 --- /dev/null +++ b/src/editor/core/event/GlobalEvent.ts @@ -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) + } + +} \ No newline at end of file diff --git a/src/editor/core/position/Position.ts b/src/editor/core/position/Position.ts new file mode 100644 index 0000000..e742d38 --- /dev/null +++ b/src/editor/core/position/Position.ts @@ -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 + } + +} \ No newline at end of file diff --git a/src/editor/core/range/RangeManager.ts b/src/editor/core/range/RangeManager.ts new file mode 100644 index 0000000..50ddb23 --- /dev/null +++ b/src/editor/core/range/RangeManager.ts @@ -0,0 +1,39 @@ +import { IEditorOption } from "../../interface/Editor" +import { IRange } from "../../interface/Range" + +export class RangeManager { + + private ctx: CanvasRenderingContext2D + private options: Required + private range: IRange + + constructor(ctx: CanvasRenderingContext2D, options: Required,) { + 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() + } + } + +} \ No newline at end of file diff --git a/src/editor/index.ts b/src/editor/index.ts index e4908c9..85b210b 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -1,525 +1,47 @@ 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' +import { IElement } from './interface/Element' +import { Draw } from './core/draw/Draw' +import { Command } from './core/command/Command' export default class Editor { - private readonly defaultOptions: Required = { - 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 - 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, + public command: Command + + constructor(canvas: HTMLCanvasElement, elementList: IElement[], options: IEditorOption = {}) { + const editorOptions: Required = { + defaultType: 'TEXT', + defaultFont: 'Yahei', + defaultSize: 16, + rangeAlpha: 0.6, + rangeColor: '#AECBFA', + marginIndicatorSize: 35, + marginIndicatorColor: '#BABABA', + margins: [100, 120, 100, 120], ...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({ + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + const dpr = window.devicePixelRatio + canvas.width = parseInt(canvas.style.width) * dpr + canvas.height = parseInt(canvas.style.height) * dpr + canvas.style.cursor = 'text' + ctx.scale(dpr, dpr) + if (elementList[0].value !== ZERO) { + elementList.unshift({ value: ZERO }) } - data.forEach(text => { + elementList.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 + // 启动 + const draw = new Draw(canvas, ctx, editorOptions, elementList) + draw.render() + // 对外命令 + this.command = new Command(draw) } } \ No newline at end of file diff --git a/src/editor/interface/Range.ts b/src/editor/interface/Range.ts index 6d9c85b..1412917 100644 --- a/src/editor/interface/Range.ts +++ b/src/editor/interface/Range.ts @@ -1,4 +1,4 @@ export interface IRange { startIndex: number; - endIndex: number + endIndex: number; } \ No newline at end of file diff --git a/src/editor/interface/Row.ts b/src/editor/interface/Row.ts index af8d44b..a6231db 100644 --- a/src/editor/interface/Row.ts +++ b/src/editor/interface/Row.ts @@ -1,4 +1,4 @@ -import { IElement } from "./Element"; +import { IElement } from "./Element" export type IRowElement = IElement & { metrics: TextMetrics diff --git a/src/editor/utils/index.ts b/src/editor/utils/index.ts index ccbc23f..03d4c33 100644 --- a/src/editor/utils/index.ts +++ b/src/editor/utils/index.ts @@ -18,15 +18,15 @@ export function writeText(text: string) { export function deepClone(obj: any) { if (!obj || typeof obj !== 'object') { - return obj; + return obj } - let newObj: any = {}; + let newObj: any = {} if (Array.isArray(obj)) { - newObj = obj.map(item => deepClone(item)); + newObj = obj.map(item => deepClone(item)) } else { Object.keys(obj).forEach((key) => { - return newObj[key] = deepClone(obj[key]); + return newObj[key] = deepClone(obj[key]) }) } - return newObj; + return newObj } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 493ace7..4980919 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,5 +42,5 @@ window.onload = function () { const instance = new Editor(canvas, data, { margins: [120, 120, 200, 120] }) - console.log('编辑器实例: ', instance); + console.log('编辑器实例: ', instance) } \ No newline at end of file