platform/packages/presentation/src/drawing.ts
Denis Tingaikin d020053d35
feat: add links preview (#7600)
Signed-off-by: Denis Tingaikin <denis.tingajkin@xored.com>
2025-01-15 12:07:01 +07:00

913 lines
27 KiB
TypeScript

//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export interface DrawingData {
content?: string
}
export interface DrawingProps {
readonly: boolean
autoSize?: boolean
imageWidth?: number
imageHeight?: number
commands: DrawingCmd[]
offset?: Point
tool?: DrawingTool
penColor?: string
penWidth?: number
eraserWidth?: number
fontSize?: number
defaultCursor?: string
changingCmdId?: string
cmdAdded?: (cmd: DrawingCmd) => void
cmdChanging?: (id: string) => void
cmdUnchanged?: (id: string) => void
cmdChanged?: (cmd: DrawingCmd) => void
cmdDeleted?: (id: string) => void
editorCreated?: (editor: HTMLDivElement) => void
panned?: (offset: Point) => void
}
export interface DrawingCmd {
id: string
type: 'line' | 'text'
}
export interface DrawTextCmd extends DrawingCmd {
text: string
pos: Point
fontSize: number
fontFace: string
color: string
}
export interface DrawLineCmd extends DrawingCmd {
lineWidth: number
erasing: boolean
penColor: string
points: Point[]
}
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text'
interface Point {
x: number
y: number
}
function avgPoint (p1: Point, p2: Point): Point {
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
}
const maxTextLength = 500
export const makeCommandId = (): string => {
return crypto.randomUUID().toString()
}
const crossSvg = `<svg height="8" width="8" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="m1.29 2.71 5.3 5.29-5.3 5.29c-.92.92.49 2.34 1.41 1.41l5.3-5.29 5.29 5.3c.92.92 2.34-.49 1.41-1.41l-5.29-5.3 5.3-5.29c.92-.93-.49-2.34-1.42-1.42l-5.29 5.3-5.29-5.3c-.93-.92-2.34.49-1.42 1.42z"/>
</svg>`
class DrawState {
on = false
tool: DrawingTool = 'pen'
penColor = 'blue'
penWidth = 4
eraserWidth = 30
minLineLength = 6
fontSize = 20
fontFace = '"IBM Plex Sans"'
center: Point = { x: 0, y: 0 }
offset: Point = { x: 0, y: 0 }
points: Point[] = []
scale: Point = { x: 1, y: 1 }
ctx: CanvasRenderingContext2D
constructor (ctx: CanvasRenderingContext2D) {
this.ctx = ctx
}
cursorWidth = (): number => {
return Math.max(8, this.tool === 'erase' ? this.eraserWidth : this.penWidth)
}
lineScale = (): number => {
return (this.scale.x + this.scale.y) / 2
}
addPoint = (mouseX: number, mouseY: number): void => {
this.points.push(this.mouseToCanvasPoint({ x: mouseX, y: mouseY }))
}
mouseToCanvasPoint = (mouse: Point): Point => {
return {
x: mouse.x * this.scale.x - this.offset.x - this.center.x,
y: mouse.y * this.scale.y - this.offset.y - this.center.y
}
}
canvasToMousePoint = (canvas: Point): Point => {
return {
x: canvas.x / this.scale.x + this.offset.x + this.center.x,
y: canvas.y / this.scale.y + this.offset.y + this.center.y
}
}
isDrawingTool = (): boolean => {
return this.tool === 'pen' || this.tool === 'erase'
}
translateCtx = (): void => {
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
}
drawLive = (x: number, y: number, lastPoint = false): void => {
window.requestAnimationFrame(() => {
if (!lastPoint || this.points.length > 1) {
this.addPoint(x, y)
}
const erasing = this.tool === 'erase'
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = this.penColor
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
if (this.points.length === 1) {
this.drawPoint(this.points[0], erasing)
} else {
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
this.ctx.stroke()
}
this.ctx.restore()
})
}
drawCommand = (cmd: DrawingCmd): void => {
if (cmd.type === 'text') {
this.drawTextCommand(cmd as DrawTextCmd)
} else {
this.drawLineCommand(cmd as DrawLineCmd)
}
}
drawLineCommand = (cmd: DrawLineCmd): void => {
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = cmd.penColor
this.ctx.lineWidth = cmd.lineWidth
this.ctx.globalCompositeOperation = cmd.erasing ? 'destination-out' : 'source-over'
if (cmd.points.length === 1) {
this.drawPoint(cmd.points[0], cmd.erasing)
} else {
for (let i = 1; i < cmd.points.length; i++) {
this.drawSmoothSegment(cmd.points, i, i === cmd.points.length - 1)
}
this.ctx.stroke()
}
this.ctx.restore()
}
drawTextCommand = (cmd: DrawTextCmd): void => {
const p = { ...cmd.pos }
this.ctx.save()
this.translateCtx()
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
this.ctx.fillStyle = cmd.color
this.ctx.textBaseline = 'top'
const lines = cmd.text.split('\n').map((l) => l.trim())
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
this.ctx.fillText(line, p.x, p.y)
p.y += cmd.fontSize
}
this.ctx.restore()
}
isPointInText = (p: Point, cmd: DrawTextCmd): boolean => {
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
const lines = cmd.text.split('\n').map((l) => l.trim())
for (let i = 0; i < lines.length; i++) {
if (p.y < cmd.pos.y + i * cmd.fontSize || p.y > cmd.pos.y + (i + 1) * cmd.fontSize) {
continue
}
const line = lines[i]
const metrics = this.ctx.measureText(line)
if (p.x < cmd.pos.x || p.x > cmd.pos.x + metrics.width) {
continue
}
return true
}
return false
}
drawPoint = (p: Point, erasing: boolean): void => {
let r = this.ctx.lineWidth / 2
if (!erasing) {
// Single point looks too small compared to a line of the same width
// So make it a bit biggers
r *= 1 / r + 1
}
this.ctx.lineWidth = 0
this.ctx.fillStyle = this.ctx.strokeStyle
this.ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
this.ctx.fill()
}
drawSmoothSegment = (points: Point[], index: number, lastPoint: boolean): void => {
const curPos = points[index]
const prevPos = points[index - 1]
const avg = avgPoint(prevPos, curPos)
if (index === 1) {
this.ctx.moveTo(prevPos.x, prevPos.y)
if (lastPoint) {
this.ctx.lineTo(curPos.x, curPos.y)
} else {
this.ctx.quadraticCurveTo(curPos.x, curPos.y, avg.x, avg.y)
}
} else {
const prevAvg = avgPoint(points[index - 2], prevPos)
this.ctx.moveTo(prevAvg.x, prevAvg.y)
if (lastPoint) {
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, curPos.x, curPos.y)
} else {
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, avg.x, avg.y)
}
}
}
}
export function drawing (
node: HTMLElement,
props: DrawingProps
): { canvas?: HTMLCanvasElement, update?: (props: DrawingProps) => void } {
if (props.autoSize !== true && (props.imageWidth === undefined || props.imageHeight === undefined)) {
console.error('Failed to create drawing: image size is not specified')
return {}
}
const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.left = '0'
canvas.style.top = '0'
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.width = props.imageWidth ?? 0
canvas.height = props.imageHeight ?? 0
node.appendChild(canvas)
const ctx = canvas.getContext('2d')
if (ctx === null) {
console.error('Failed to create drawing: unable to get 2d canvas context')
node.removeChild(canvas)
return {}
}
const canvasCursor = document.createElement('div')
canvasCursor.style.visibility = 'hidden'
canvasCursor.style.position = 'absolute'
canvasCursor.style.borderRadius = '50%'
canvasCursor.style.border = 'none'
canvasCursor.style.cursor = 'none'
canvasCursor.style.pointerEvents = 'none'
canvasCursor.style.left = '50%'
canvasCursor.style.top = '50%'
node.appendChild(canvasCursor)
let readonly = props.readonly ?? false
let prevPos: Point = { x: 0, y: 0 }
const draw = new DrawState(ctx)
draw.tool = props.tool ?? draw.tool
draw.penColor = props.penColor ?? draw.penColor
draw.penWidth = props.penWidth ?? draw.penWidth
draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth
draw.fontSize = props.fontSize ?? draw.fontSize
draw.offset = props.offset ?? draw.offset
updateCanvasCursor()
updateCanvasTouchAction()
interface LiveTextBox {
pos: Point
box: HTMLDivElement
editor: HTMLDivElement
cmdId: string
}
let liveTextBox: LiveTextBox | undefined
let commands = props.commands
replayCommands()
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === canvas) {
if (props.autoSize === true) {
draw.scale = { x: 1, y: 1 }
canvas.width = Math.floor(entry.contentRect.width)
canvas.height = Math.floor(entry.contentRect.height)
draw.center.x = canvas.width / 2
draw.center.y = canvas.height / 2
replayCommands()
} else {
draw.scale = {
x: canvas.width / entry.contentRect.width,
y: canvas.height / entry.contentRect.height
}
}
}
}
})
resizeObserver.observe(canvas)
let touchId: number | undefined
function findTouch (touches: TouchList, id: number | undefined = touchId): Touch | undefined {
for (let i = 0; i < touches.length; i++) {
const touch = touches[i]
if (touch.identifier === id) {
return touch
}
}
}
function touchToNodePoint (touch: Touch, node: HTMLElement): Point {
const rect = node.getBoundingClientRect()
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
}
}
function pointerToNodePoint (e: PointerEvent): Point {
return { x: e.offsetX, y: e.offsetY }
}
canvas.ontouchstart = (e) => {
if (readonly) {
return
}
const touch = e.changedTouches[0]
touchId = touch.identifier
drawStart(touchToNodePoint(touch, canvas))
}
canvas.ontouchmove = (e) => {
if (readonly) {
return
}
const touch = findTouch(e.changedTouches)
if (touch !== undefined) {
drawContinue(touchToNodePoint(touch, canvas))
}
}
canvas.ontouchend = (e) => {
if (readonly) {
return
}
const touch = findTouch(e.changedTouches)
if (touch !== undefined) {
drawEnd(touchToNodePoint(touch, canvas))
}
touchId = undefined
}
canvas.ontouchcancel = canvas.ontouchend
canvas.onpointerdown = (e) => {
if (readonly) {
return
}
if (e.button !== 0) {
return
}
e.preventDefault()
canvas.setPointerCapture(e.pointerId)
drawStart(pointerToNodePoint(e))
}
canvas.onpointermove = (e) => {
if (readonly) {
return
}
e.preventDefault()
drawContinue(pointerToNodePoint(e))
}
canvas.onpointerup = (e) => {
if (readonly) {
return
}
e.preventDefault()
canvas.releasePointerCapture(e.pointerId)
drawEnd(pointerToNodePoint(e))
}
canvas.onpointercancel = canvas.onpointerup
canvas.onpointerenter = () => {
if (!readonly && draw.isDrawingTool()) {
canvasCursor.style.visibility = 'visible'
}
}
canvas.onpointerleave = () => {
if (!readonly && draw.isDrawingTool()) {
canvasCursor.style.visibility = 'hidden'
}
}
function drawStart (p: Point): void {
draw.on = true
draw.points = []
prevPos = p
if (draw.isDrawingTool()) {
draw.addPoint(p.x, p.y)
}
}
function drawContinue (p: Point): void {
if (draw.isDrawingTool()) {
const w = draw.cursorWidth()
canvasCursor.style.left = `${p.x - w / 2}px`
canvasCursor.style.top = `${p.y - w / 2}px`
if (draw.on) {
if (Math.hypot(prevPos.x - p.x, prevPos.y - p.y) < draw.minLineLength) {
return
}
draw.drawLive(p.x, p.y)
prevPos = p
}
}
if (draw.on && draw.tool === 'pan') {
requestAnimationFrame(() => {
draw.offset.x += p.x - prevPos.x
draw.offset.y += p.y - prevPos.y
replayCommands()
prevPos = p
})
}
if (draw.on && draw.tool === 'text') {
prevPos = p
}
}
function drawEnd (p: Point): void {
if (draw.on) {
if (draw.isDrawingTool()) {
draw.drawLive(p.x, p.y, true)
storeLineCommand()
} else if (draw.tool === 'pan') {
props.panned?.(draw.offset)
} else if (draw.tool === 'text') {
if (liveTextBox !== undefined) {
storeTextCommand()
closeLiveTextBox()
} else {
const cmd = findTextCommand(prevPos)
props.cmdChanging?.(cmd?.id ?? '')
}
}
draw.on = false
}
}
function findTextCommand (mousePos: Point): DrawTextCmd | undefined {
const pos = draw.mouseToCanvasPoint(mousePos)
for (let i = commands.length - 1; i >= 0; i--) {
const anyCmd = commands[i]
if (anyCmd.type === 'text') {
const cmd = anyCmd as DrawTextCmd
if (draw.isPointInText(pos, cmd)) {
return cmd
}
}
}
return undefined
}
function makeLiveTextBox (cmdId: string): void {
let pos = prevPos
let existingCmd: DrawTextCmd | undefined
for (const cmd of commands) {
if (cmd.id === cmdId && cmd.type === 'text') {
existingCmd = cmd as DrawTextCmd
pos = draw.canvasToMousePoint(existingCmd.pos)
break
}
}
const padding = 6
const handleSize = 14
const box = document.createElement('div')
box.style.zIndex = '1'
box.style.position = 'absolute'
box.style.left = `calc(${pos.x}px - ${padding}px)`
box.style.top = `calc(${pos.y}px - ${padding}px)`
box.style.border = '1px solid var(--theme-editbox-focus-border)'
box.style.borderRadius = 'var(--small-BorderRadius)'
box.style.padding = `${padding}px`
box.style.background = 'var(--theme-popup-header)'
box.style.touchAction = 'none'
box.addEventListener('mousedown', (e) => {
e.stopPropagation()
})
box.addEventListener('click', (e) => {
e.stopPropagation()
editor.focus()
})
const editor = document.createElement('div')
editor.style.cursor = 'text'
editor.style.padding = '0'
editor.contentEditable = 'true'
editor.style.outline = 'none'
editor.style.minWidth = '2rem'
editor.style.whiteSpace = 'nowrap'
if (existingCmd !== undefined) {
editor.innerText = existingCmd.text
}
editor.addEventListener('input', (e) => {
if (editor.innerText.length > maxTextLength) {
e.preventDefault()
editor.innerText = editor.innerText.substring(0, maxTextLength)
moveCaretToEnd()
}
})
editor.addEventListener('paste', (e) => {
e.preventDefault()
const selection = window.getSelection()
const text = (e.clipboardData?.getData('text/plain') ?? '').trim()
if (text.length === 0 || selection === null || selection.rangeCount === 0) {
return
}
let selectedLen = 0
const range = selection.getRangeAt(0)
if (editor.contains(range.commonAncestorContainer)) {
selectedLen = range.endOffset - range.startOffset
}
const availableLen = maxTextLength - (selectedLen > 0 ? selectedLen : editor.innerText.length)
if (availableLen > 0) {
const lines = text.slice(0, availableLen).split('\n')
const pastedNode = document.createDocumentFragment()
for (let i = 0; i < lines.length; i++) {
pastedNode.appendChild(document.createTextNode(lines[i]))
if (i < lines.length - 1) {
pastedNode.appendChild(document.createElement('br'))
}
}
range.deleteContents()
range.insertNode(pastedNode)
// move caret to the end of pasted node
range.collapse(false)
selection.addRange(range)
}
})
editor.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (liveTextBox !== undefined) {
const cmdId = liveTextBox.cmdId
// reset changingCmdId in clients
setTimeout(() => {
props.cmdUnchanged?.(cmdId)
}, 0)
}
closeLiveTextBox()
replayCommands()
} else if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
storeTextCommand()
closeLiveTextBox()
}
})
box.appendChild(editor)
const moveCaretToEnd = (): void => {
const selection = window.getSelection()
const range = document.createRange()
range.setStartAfter(editor.lastChild ?? editor)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
}
const selectAll = (): void => {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editor)
selection?.removeAllRanges()
selection?.addRange(range)
}
const makeHandle = (): HTMLDivElement => {
const handle = document.createElement('div')
handle.style.position = 'absolute'
handle.style.top = `-${handleSize / 2}px`
handle.style.width = `${handleSize}px`
handle.style.height = `${handleSize}px`
handle.style.color = 'var(--global-on-accent-TextColor)'
handle.style.background = 'var(--global-accent-IconColor)'
handle.style.borderRadius = '50%'
handle.style.display = 'flex'
handle.style.alignItems = 'center'
handle.style.justifyContent = 'center'
return handle
}
const moveTextBox = (dx: number, dy: number): void => {
let newX = box.offsetLeft + dx
let newY = box.offsetTop + dy
// For screenshots the canvas always has the same size as the underlying image
// and we should not be able to drag the text box outside of the screenshot
if (props.autoSize !== true) {
newX = Math.max(0, newX)
newY = Math.max(0, newY)
if (newX + box.offsetWidth > node.clientWidth) {
newX = node.clientWidth - box.offsetWidth
}
if (newY + box.offsetHeight > node.clientHeight) {
newY = node.clientHeight - box.offsetHeight
}
}
box.style.left = `${newX}px`
box.style.top = `${newY}px`
if (liveTextBox !== undefined) {
liveTextBox.pos.x = newX + padding
liveTextBox.pos.y = newY + padding
}
}
const dragHandle = makeHandle()
dragHandle.style.left = `-${handleSize / 2}px`
dragHandle.style.cursor = 'grab'
dragHandle.style.touchAction = 'none'
dragHandle.addEventListener('pointerdown', (e) => {
e.preventDefault()
dragHandle.style.cursor = 'grabbing'
dragHandle.setPointerCapture(e.pointerId)
let prevPos = { x: e.clientX, y: e.clientY }
const pointerMove = (e: PointerEvent): void => {
e.preventDefault()
const p = { x: e.clientX, y: e.clientY }
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
prevPos = p
}
const pointerUp = (e: PointerEvent): void => {
setTimeout(() => {
editor.focus()
}, 100)
e.preventDefault()
dragHandle.style.cursor = 'grab'
dragHandle.releasePointerCapture(e.pointerId)
dragHandle.removeEventListener('pointermove', pointerMove)
dragHandle.removeEventListener('pointerup', pointerUp)
dragHandle.removeEventListener('pointercancel', pointerUp)
}
dragHandle.addEventListener('pointermove', pointerMove)
dragHandle.addEventListener('pointerup', pointerUp)
dragHandle.addEventListener('pointercancel', pointerUp)
})
dragHandle.addEventListener('touchstart', (e) => {
dragHandle.style.cursor = 'grabbing'
const touch = e.changedTouches[0]
const touchId = touch.identifier
let prevPos = touchToNodePoint(touch, dragHandle)
const touchMove = (e: TouchEvent): void => {
const touch = findTouch(e.changedTouches, touchId)
if (touch !== undefined) {
const p = touchToNodePoint(touch, dragHandle)
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
prevPos = p
}
}
const touchEnd = (e: TouchEvent): void => {
setTimeout(() => {
editor.focus()
}, 100)
dragHandle.style.cursor = 'grab'
dragHandle.removeEventListener('touchmove', touchMove)
dragHandle.removeEventListener('touchend', touchEnd)
dragHandle.removeEventListener('touchcancel', touchEnd)
}
dragHandle.addEventListener('touchmove', touchMove)
dragHandle.addEventListener('touchend', touchEnd)
dragHandle.addEventListener('touchcancel', touchEnd)
})
box.appendChild(dragHandle)
const deleteButton = makeHandle()
deleteButton.style.right = `-${handleSize / 2}px`
deleteButton.style.cursor = 'pointer'
deleteButton.innerHTML = crossSvg
deleteButton.addEventListener('click', () => {
node.removeChild(box)
if (liveTextBox?.cmdId !== undefined) {
props.cmdDeleted?.(liveTextBox.cmdId)
}
liveTextBox = undefined
})
box.appendChild(deleteButton)
node.appendChild(box)
liveTextBox = { box, editor, pos, cmdId }
updateLiveTextBox()
setTimeout(() => {
editor.focus()
}, 100)
selectAll()
props.editorCreated?.(editor)
}
function updateLiveTextBox (): void {
if (liveTextBox !== undefined) {
liveTextBox.editor.style.color = draw.penColor
liveTextBox.editor.style.lineHeight = `${draw.fontSize / draw.lineScale()}px`
liveTextBox.editor.style.fontSize = `${draw.fontSize / draw.lineScale()}px`
liveTextBox.editor.style.fontFamily = draw.fontFace
}
}
function closeLiveTextBox (): void {
if (liveTextBox !== undefined) {
node.removeChild(liveTextBox.box)
liveTextBox = undefined
}
}
function storeTextCommand (defer = false): void {
if (liveTextBox !== undefined) {
const text = (liveTextBox.editor.innerText ?? '').trim()
if (text !== '') {
const cmdId = liveTextBox.cmdId
const cmd: DrawTextCmd = {
id: cmdId === '' ? makeCommandId() : cmdId,
type: 'text',
text,
pos: draw.mouseToCanvasPoint(liveTextBox.pos),
fontSize: draw.fontSize,
fontFace: draw.fontFace,
color: draw.penColor
}
const notify = (): void => {
if (cmdId !== '') {
props.cmdChanged?.(cmd)
} else {
props.cmdAdded?.(cmd)
}
}
if (defer) {
setTimeout(notify, 0)
} else {
notify()
}
} else {
props.cmdUnchanged?.(liveTextBox.cmdId)
}
}
}
function storeLineCommand (): void {
if (draw.points.length > 0) {
const erasing = draw.tool === 'erase'
const cmd: DrawLineCmd = {
id: makeCommandId(),
type: 'line',
lineWidth: erasing ? draw.eraserWidth : draw.penWidth,
erasing,
penColor: draw.penColor,
points: draw.points
}
props.cmdAdded?.(cmd)
}
}
function updateCanvasCursor (): void {
if (readonly) {
canvasCursor.style.visibility = 'hidden'
canvas.style.cursor = props.defaultCursor ?? 'default'
} else if (draw.isDrawingTool()) {
canvas.style.cursor = 'none'
canvasCursor.style.visibility = 'visible'
const erasing = draw.tool === 'erase'
const w = draw.cursorWidth()
canvasCursor.style.background = erasing ? 'none' : draw.penColor
canvasCursor.style.boxShadow = erasing
? '0px 0px 1px 1px white inset, 0px 0px 2px 1px black'
: '0px 0px 3px 0px var(--theme-button-contrast-enabled)'
canvasCursor.style.width = `${w}px`
canvasCursor.style.height = `${w}px`
} else if (draw.tool === 'pan') {
canvas.style.cursor = 'move'
canvasCursor.style.visibility = 'hidden'
} else if (draw.tool === 'text') {
canvas.style.cursor = 'text'
canvasCursor.style.visibility = 'hidden'
} else {
canvas.style.cursor = 'default'
canvasCursor.style.visibility = 'hidden'
}
}
function updateCanvasTouchAction (): void {
canvas.style.touchAction = readonly ? 'unset' : 'none'
}
function replayCommands (): void {
draw.ctx.reset()
for (const cmd of commands) {
if (cmd.id !== undefined && liveTextBox?.cmdId === cmd.id) {
continue
}
draw.drawCommand(cmd)
}
}
return {
canvas,
update (props: DrawingProps) {
let replay = false
if (props.offset !== undefined && (props.offset.x !== draw.offset.x || props.offset.y !== draw.offset.y)) {
draw.offset = props.offset
replay = true
}
if (commands !== props.commands) {
commands = props.commands
replay = true
}
let updateCursor = false
let updateTextBox = false
if (draw.tool !== props.tool) {
draw.tool = props.tool ?? 'pen'
updateCursor = true
}
if (draw.penColor !== props.penColor) {
draw.penColor = props.penColor ?? 'blue'
updateTextBox = true
updateCursor = true
}
if (draw.penWidth !== props.penWidth) {
draw.penWidth = props.penWidth ?? draw.penWidth
updateCursor = true
}
if (draw.eraserWidth !== props.eraserWidth) {
draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth
updateCursor = true
}
if (draw.fontSize !== props.fontSize) {
draw.fontSize = props.fontSize ?? draw.fontSize
updateTextBox = true
}
if (props.readonly !== readonly) {
readonly = props.readonly ?? false
updateCanvasTouchAction()
updateCursor = true
}
if (props.changingCmdId === undefined) {
if (liveTextBox !== undefined) {
storeTextCommand(true)
closeLiveTextBox()
replay = true
}
} else {
if (liveTextBox === undefined) {
makeLiveTextBox(props.changingCmdId)
replay = true
} else if (liveTextBox.cmdId !== props.changingCmdId) {
storeTextCommand(true)
closeLiveTextBox()
replay = true
}
}
if (updateCursor) {
updateCanvasCursor()
}
if (updateTextBox) {
updateLiveTextBox()
}
if (replay) {
replayCommands()
}
}
}
}