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
+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M8.131 6.9c2.035 0 2.569-.9 2.569-1.869 0-.968-.64-1.831-2.623-1.831H5.2v3.7h2.931zm.524 5.9c2.045 0 2.545-1.305 2.545-2.3 0-.985-.506-2.4-2.81-2.4H5.2v4.7h3.455zM4 2h4.71c2.367 0 3.19 1.583 3.19 3s-.325 1.852-1.1 2.5c1.2.5 1.569 1.379 1.6 3 .03 1.606-.586 3.5-3.769 3.5H4V2z" fill="#3D4757" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 411 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M7.997 3.429L6.398 8h3.2L7.997 3.429zM8.497 2L12 12h-1L9.949 9h-3.9L5 12H4L7.496 2h1z" fill="#3D4757" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 221 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M10.42 7.903H6.692a9.182 9.182 0 01-.41-.172 5.54 5.54 0 01-.814-.447 2.955 2.955 0 01-.655-.595 2.728 2.728 0 01-.44-.777 2.877 2.877 0 01-.162-1.006c0-.472.094-.888.282-1.25.188-.36.453-.663.793-.907s.747-.43 1.22-.558A5.97 5.97 0 018.063 2c.504 0 .95.049 1.337.147.387.097.725.23 1.013.398.287.169.53.365.73.59a3.337 3.337 0 01.772 1.486c.03.13.054.255.073.379h-1.276a2.393 2.393 0 00-.22-.615 2.315 2.315 0 00-.59-.724 2.467 2.467 0 00-.834-.44 3.376 3.376 0 00-1.005-.146 4.69 4.69 0 00-.958.097 2.77 2.77 0 00-.839.314 1.765 1.765 0 00-.597.566c-.152.233-.229.518-.229.854 0 .348.086.642.258.884.171.241.401.449.689.622.287.174.615.323.983.448s.749.247 1.142.367c.31.097.62.196.934.297a8.439 8.439 0 01.973.38zm1.376 1c.175.217.315.466.418.746.105.285.158.612.158.98 0 .554-.104 1.041-.312 1.462-.207.42-.496.772-.867 1.054-.37.282-.81.495-1.32.64A6.12 6.12 0 018.205 14c-.543 0-1.071-.09-1.586-.273a4.44 4.44 0 01-1.374-.773 3.873 3.873 0 01-.97-1.217 3.695 3.695 0 01-.395-1.612h1.27c.028.407.122.78.282 1.12a2.835 2.835 0 001.581 1.465c.363.138.76.207 1.192.207.387 0 .758-.042 1.112-.126a2.85 2.85 0 00.938-.399 2.01 2.01 0 00.647-.708c.16-.29.241-.642.241-1.054 0-.337-.087-.623-.261-.86a2.333 2.333 0 00-.69-.61 4.651 4.651 0 00-.495-.257h2.099z"/><path d="M3 7h10v1H3z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M8.213 13H6.8l6.636-6.636-4.243-4.243-7.07 7.071L5.928 13H4.515L1.06 9.546a.5.5 0 010-.707L8.839 1.06a.5.5 0 01.707 0l4.95 4.95a.5.5 0 010 .707L8.213 13z" fill-rule="nonzero"/><path d="M4.536 6.364l4.95 4.95-.707.707-4.95-4.95zM4.521 13h10.03v1H5.496z"/></g></svg>

After

Width:  |  Height:  |  Size: 394 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M13.31 5h-1.92a2.203 2.203 0 00-.39-.034c-.135 0-.27.012-.402.034H10v.18c-.578.256-1 .714-1 1.214v1.928c0 .196.117.43.3.658v1.23a3.543 3.543 0 01-.3-.4V11H8V1h1v4.265C9 4.763 10 4 10.942 4c1.19 0 1.92.422 2.367 1zM2 6c-.03-.498-.175-2 2.5-2C7.11 4 7 5 7 6.902V11H5.984v-.993c.38.662-.115.993-1.484.993C2.708 11 2 9.931 2 9c0-1.428.447-2 2.5-2h1.484c0-1 .031-2-1.484-2-1.533 0-1.577.485-1.577 1H2zm2.5 2C3.601 8 3 7.768 3 9c0 1.31.438 1 1.5 1 .617 0 1.484-.665 1.484-1.847V8H4.5z"/><path d="M13.085 6.316l-2.814 3a1 1 0 101.458 1.368l2.815-3a1 1 0 00-1.459-1.368z" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 725 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M10.017 3L8.08 13H9v1H6v-1h1.182L9 3H8V2h3v1h-.983z" fill="#3D4757"/></svg>

After

Width:  |  Height:  |  Size: 167 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M7.5 2v2.5H4a.5.5 0 00-.5.5v2a.5.5 0 00.5.5h9a.5.5 0 00.5-.5V5a.5.5 0 00-.5-.5H9.5V2a.5.5 0 00-.5-.5H8a.5.5 0 00-.5.5z" stroke="#3D4757"/><path fill="#3D4757" d="M13 7h1v4h-1z"/><path d="M11 13a2 2 0 002-2V8.764A3 3 0 118.764 13H11z" fill="#3D4757"/><path fill="#3D4757" d="M1 13h10v1H1z"/><path d="M1 13a2 2 0 002-2V8.764A3 3 0 011 14v-1z" fill="#3D4757"/><path fill="#3D4757" d="M3 7h1v4H3z"/></g></svg>

After

Width:  |  Height:  |  Size: 532 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M12 4h-1V2H5v2H4V2a1 1 0 011-1h6a1 1 0 011 1v2zm0 5v4a1 1 0 01-1 1H5a1 1 0 01-1-1V9h1v4h6V9h1z"/><path d="M12 12v-1h2V5H2v6h2v1H2a1 1 0 01-1-1V5a1 1 0 011-1h12a1 1 0 011 1v6a1 1 0 01-1 1h-2z"/><path d="M3 8h10v1H3zm8-2h2v1h-2z"/></g></svg>

After

Width:  |  Height:  |  Size: 369 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M3 14v-3a4 4 0 014-4h3V6H7a5 5 0 00-5 5v3h1zm7.016-11.282v7.543l4.29-3.73z" fill="#3D4757"/></svg>

After

Width:  |  Height:  |  Size: 190 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle stroke="#3D4757" cx="6" cy="6" r="4.5"/><path d="M10.061 10.968L8.707 9.414l.707-.707 1.514 1.457.435-.404 2.632 2.462a1.154 1.154 0 01.05 1.635 1.184 1.184 0 01-1.655.064l-2.788-2.527.46-.426z" fill="#3D4757" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 367 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M6.215 3.29H7.64L11.855 14H10.52l-1.14-3H4.46l-1.14 3H2L6.215 3.29zM4.85 9.965h4.14L6.965 4.61h-.06L4.85 9.965z"/><path d="M12 4V2h1v2h2v1h-2v2h-1V5h-2V4h2z" fill-rule="nonzero"/></g></svg>

After

Width:  |  Height:  |  Size: 319 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path fill-rule="nonzero" d="M11 4h4v1h-4z"/><path d="M6.215 3.29H7.64L11.855 14H10.52l-1.14-3H4.46l-1.14 3H2L6.215 3.29zM4.85 9.965h4.14L6.965 4.61h-.06L4.85 9.965z"/></g></svg>

After

Width:  |  Height:  |  Size: 299 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M5 2v6a3 3 0 106 0V2h1v6a4 4 0 11-8 0V2h1zM4 13h8v1H4z" fill="#3D4757"/></svg>

After

Width:  |  Height:  |  Size: 170 B

+1
View File
@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M6 2.763v7.544l-4.29-3.73zM13 14v-3a4 4 0 00-4-4H6V6h3a5 5 0 015 5v3h-1z" fill="#3D4757"/></svg>

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

+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;
}
+46
View File
@@ -0,0 +1,46 @@
import './style.css'
import Editor from './editor'
window.onload = function () {
const canvas = document.querySelector<HTMLCanvasElement>('canvas')
if (!canvas) return
const text = `\n主诉:\n发热三天,咳嗽五天。\n现病史:\n发病前14天内有病历报告社区的旅行时或居住史;发病前14天内与新型冠状病毒感染的患者或无症状感染者有接触史;发病前14天内解除过来自病历报告社区的发热或有呼吸道症状的患者;聚集性发病,2周内在小范围如家庭、办公室、学校班级等场所,出现2例及以上发热或呼吸道症状的病例。\n既往史:\n有糖尿病10年,有高血压2年,有传染性疾病1年。\n体格检查:\nT36.5℃,P80bpmR20次/分,BP120/80mmHg\n辅助检查:\n2020年6月10日,普放:血细胞比容36.50%(偏低)40~50;单核细胞绝对值0.75*10^9/L(偏高)参考值:0.10.6\n门诊诊断:\n1.高血压\n处置治疗:\n1.超声引导下甲状腺细针穿刺术;\n2.乙型肝炎表面抗体测定;\n3.膜式病变细胞采集术、后颈皮下肤层;\n4.氯化钠注射液 250ml/袋、1袋;\n5.七叶皂苷钠片(欧开)、30mg/片*24/盒、1片、口服、BID(每日两次)、1天`
// 模拟加粗字
const boldText = ['主诉:', '现病史:', '既往史:', '体格检查:', '辅助检查:', '门诊诊断:', '处置治疗:']
const boldIndex: number[] = boldText.map(b => {
const i = text.indexOf(b)
return ~i ? Array(b.length).fill(i).map((_, j) => i + j) : []
}).flat()
// 模拟颜色字
const colorText = ['传染性疾病']
const colorIndex: number[] = colorText.map(b => {
const i = text.indexOf(b)
return ~i ? Array(b.length).fill(i).map((_, j) => i + j) : []
}).flat()
// 组合数据
const data = text.split('').map((value, index) => {
if (boldIndex.includes(index)) {
return {
value,
size: 18,
bold: true
}
}
if (colorIndex.includes(index)) {
return {
value,
color: 'red',
size: 16
}
}
return {
value,
size: 16
}
})
// 初始化编辑器
const instance = new Editor(canvas, data, {
margins: [120, 120, 200, 120]
})
console.log('编辑器实例: ', instance);
}
+143
View File
@@ -0,0 +1,143 @@
* {
margin: 0;
padding: 0;
}
body {
background-color: #F2F4F7;
}
.menu {
width: 100%;
height: 60px;
top: 0;
z-index: 9;
position: fixed;
display: flex;
align-items: center;
justify-content: center;
background: #F2F4F7;
box-shadow: 0 2px 4px 0 transparent;
}
.menu-divider {
width: 1px;
height: 16px;
margin: 0 6px;
display: inline-block;
background-color: #cfd2d8;
}
.menu-item {
height: 24px;
display: flex;
align-items: center;
}
.menu-item>div {
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin: 0 6px;
}
.menu-item>div:hover {
background: rgba(25, 55, 88, .04);
}
.menu-item i {
width: 16px;
height: 16px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.menu-item>div span {
width: 16px;
height: 3px;
display: inline-block;
border: 1px solid #e2e6ed;
}
.menu-item__undo i {
background-image: url('./assets/images/undo.svg');
}
.menu-item__redo i {
background-image: url('./assets/images/redo.svg');
}
.menu-item__painter i {
background-image: url('./assets/images/painter.svg');
}
.menu-item__format i {
background-image: url('./assets/images/format.svg');
}
.menu-item__size-add i {
background-image: url('./assets/images/size-add.svg');
}
.menu-item__size-minus i {
background-image: url('./assets/images/size-minus.svg');
}
.menu-item__bold i {
background-image: url('./assets/images/bold.svg');
}
.menu-item__italic i {
background-image: url('./assets/images/italic.svg');
}
.menu-item__underline i {
background-image: url('./assets/images/underline.svg');
}
.menu-item__deleteline i {
background-image: url('./assets/images/deleteline.svg');
}
.menu-item__color,
.menu-item__highlight {
display: flex;
flex-direction: column;
}
.menu-item__color i {
background-image: url('./assets/images/color.svg');
}
.menu-item__color span {
background-color: #000000;
}
.menu-item__highlight i {
background-image: url('./assets/images/highlight.svg');
}
.menu-item__highlight span {
background-color: #ffff00;
}
.menu-item__search i {
background-image: url('./assets/images/search.svg');
}
.menu-item__print i {
background-image: url('./assets/images/print.svg');
}
.editor {
width: 794px;
height: 1123px;
margin: 80px auto;
position: relative;
background-color: #ffffff;
box-shadow: rgb(158 161 165 / 40%) 0px 2px 12px 0px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />