feat:rich text editor by canvas
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 87 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ZERO = '\u200B'
|
||||
export const WRAP = '\n'
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface IDrawOption {
|
||||
curIndex?: number;
|
||||
isSetCursor?: boolean
|
||||
isSubmitHistory?: boolean;
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IRange {
|
||||
startIndex: number;
|
||||
endIndex: number
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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体格检查:\nT:36.5℃,P:80bpm,R:20次/分,BP:120/80mmHg;\n辅助检查:\n2020年6月10日,普放:血细胞比容36.50%(偏低)40~50;单核细胞绝对值0.75*10^9/L(偏高)参考值:0.1~0.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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||