mirror of
https://github.com/hcengineering/platform.git
synced 2025-02-11 21:32:25 +00:00
399 lines
11 KiB
TypeScript
399 lines
11 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 {
|
|
id?: string
|
|
content?: string
|
|
}
|
|
|
|
export interface DrawingProps {
|
|
readonly?: boolean
|
|
imageWidth?: number
|
|
imageHeight?: number
|
|
drawingData?: DrawingData
|
|
saveDrawing?: (data: any) => Promise<void>
|
|
|
|
drawingTool?: DrawingTool
|
|
penColor?: string
|
|
}
|
|
|
|
interface DrawCmd {
|
|
lineWidth: number
|
|
erasing: boolean
|
|
penColor: string
|
|
points: Point[]
|
|
}
|
|
|
|
export type DrawingTool = 'pen' | 'erase' | 'pan'
|
|
|
|
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 }
|
|
}
|
|
|
|
class DrawState {
|
|
on = false
|
|
tool: DrawingTool = 'pen'
|
|
penColor = 'blue'
|
|
penWidth = 4
|
|
eraserWidth = 30
|
|
minLineLength = 6
|
|
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({
|
|
x: mouseX * this.scale.x - this.offset.x,
|
|
y: mouseY * this.scale.y - this.offset.y
|
|
})
|
|
}
|
|
|
|
isDrawingTool = (): boolean => {
|
|
return this.tool === 'pen' || this.tool === 'erase'
|
|
}
|
|
|
|
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.beginPath()
|
|
this.ctx.lineCap = 'round'
|
|
this.ctx.strokeStyle = this.penColor
|
|
this.ctx.lineWidth = (erasing ? this.eraserWidth : this.penWidth) * this.lineScale()
|
|
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()
|
|
}
|
|
})
|
|
}
|
|
|
|
drawCommand = (cmd: DrawCmd): void => {
|
|
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()
|
|
}
|
|
}
|
|
|
|
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.5
|
|
}
|
|
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): any {
|
|
if (
|
|
props.imageWidth === undefined ||
|
|
props.imageHeight === undefined ||
|
|
node.clientWidth === undefined ||
|
|
node.clientHeight === 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
|
|
canvas.height = props.imageHeight
|
|
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.cursor = 'none'
|
|
canvasCursor.style.pointerEvents = 'none'
|
|
node.appendChild(canvasCursor)
|
|
|
|
let readonly = props.readonly ?? false
|
|
let prevPos: Point = { x: 0, y: 0 }
|
|
|
|
const draw = new DrawState(ctx)
|
|
draw.tool = props.drawingTool ?? 'pan'
|
|
draw.penColor = props.penColor ?? 'blue'
|
|
updateCanvasCursor()
|
|
|
|
let modified = false
|
|
let commands: DrawCmd[] = []
|
|
let drawingData = props.drawingData
|
|
parseData()
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.target === canvas) {
|
|
draw.scale = {
|
|
x: canvas.width / entry.contentRect.width,
|
|
y: canvas.height / entry.contentRect.height
|
|
}
|
|
}
|
|
}
|
|
})
|
|
resizeObserver.observe(canvas)
|
|
|
|
canvas.onpointerdown = (e) => {
|
|
if (readonly) {
|
|
return
|
|
}
|
|
if (e.button !== 0) {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
canvas.setPointerCapture(e.pointerId)
|
|
|
|
const x = e.offsetX
|
|
const y = e.offsetY
|
|
|
|
draw.on = true
|
|
draw.points = []
|
|
prevPos = { x, y }
|
|
if (draw.isDrawingTool()) {
|
|
draw.addPoint(x, y)
|
|
}
|
|
}
|
|
|
|
canvas.onpointermove = (e) => {
|
|
if (readonly) {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
|
|
const x = e.offsetX
|
|
const y = e.offsetY
|
|
|
|
if (draw.isDrawingTool()) {
|
|
const w = draw.cursorWidth()
|
|
canvasCursor.style.left = `${x - w / 2}px`
|
|
canvasCursor.style.top = `${y - w / 2}px`
|
|
if (draw.on) {
|
|
if (Math.hypot(prevPos.x - x, prevPos.y - y) < draw.minLineLength) {
|
|
return
|
|
}
|
|
draw.drawLive(x, y)
|
|
prevPos = { x, y }
|
|
}
|
|
}
|
|
|
|
// TODO: if (draw.tool === 'pan')
|
|
// Currently we only show drawing over attached images
|
|
// Their sizes are fixed and no pan required
|
|
}
|
|
|
|
canvas.onpointerup = (e) => {
|
|
if (readonly) {
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
canvas.releasePointerCapture(e.pointerId)
|
|
if (draw.on) {
|
|
if (draw.isDrawingTool()) {
|
|
draw.drawLive(e.offsetX, e.offsetY, true)
|
|
storeCommand()
|
|
}
|
|
draw.on = false
|
|
}
|
|
}
|
|
|
|
canvas.onpointerenter = () => {
|
|
if (!readonly && draw.isDrawingTool()) {
|
|
canvasCursor.style.visibility = 'visible'
|
|
}
|
|
}
|
|
|
|
canvas.onpointerleave = () => {
|
|
if (!readonly && draw.isDrawingTool()) {
|
|
canvasCursor.style.visibility = 'hidden'
|
|
}
|
|
}
|
|
|
|
function storeCommand (): void {
|
|
if (draw.points.length > 1) {
|
|
const erasing = draw.tool === 'erase'
|
|
const cmd: DrawCmd = {
|
|
lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(),
|
|
erasing,
|
|
penColor: draw.penColor,
|
|
points: draw.points
|
|
}
|
|
commands.push(cmd)
|
|
modified = true
|
|
}
|
|
}
|
|
|
|
function updateCanvasCursor (): void {
|
|
if (readonly) {
|
|
canvasCursor.style.visibility = 'hidden'
|
|
canvas.style.cursor = 'default'
|
|
} else if (draw.isDrawingTool()) {
|
|
canvas.style.cursor = 'none'
|
|
const erasing = draw.tool === 'erase'
|
|
const w = draw.cursorWidth()
|
|
canvasCursor.style.background = erasing ? 'none' : draw.penColor
|
|
canvasCursor.style.border = erasing ? '1px solid #333' : 'none'
|
|
canvasCursor.style.boxShadow = erasing ? '0px 0px 0px 1px #eee inset' : 'none'
|
|
canvasCursor.style.width = `${w}px`
|
|
canvasCursor.style.height = `${w}px`
|
|
} else if (draw.tool === 'pan') {
|
|
canvas.style.cursor = 'move'
|
|
canvasCursor.style.visibility = 'hidden'
|
|
} else {
|
|
canvas.style.cursor = 'default'
|
|
canvasCursor.style.visibility = 'hidden'
|
|
}
|
|
}
|
|
|
|
function clearCanvas (): void {
|
|
draw.ctx.reset()
|
|
draw.offset = { x: 0, y: 0 }
|
|
}
|
|
|
|
function replayCommands (): void {
|
|
draw.ctx.reset()
|
|
for (const cmd of commands) {
|
|
draw.drawCommand(cmd)
|
|
}
|
|
}
|
|
|
|
function parseData (): void {
|
|
clearCanvas()
|
|
if (drawingData?.content !== undefined) {
|
|
try {
|
|
commands = JSON.parse(drawingData.content)
|
|
replayCommands()
|
|
} catch (error) {
|
|
commands = []
|
|
console.error('Failed to parse drawing content', error)
|
|
}
|
|
} else {
|
|
commands = []
|
|
}
|
|
}
|
|
|
|
return {
|
|
update (props: DrawingProps) {
|
|
if (drawingData !== props.drawingData) {
|
|
// Currently it expectes only the empty data on update
|
|
// which means we pressed the "Clear canvas" button
|
|
// We don't support yet creation of multiple drawings for the same image
|
|
// so preserve the id to continue editing the previous drawing
|
|
const oldId = drawingData?.id
|
|
drawingData = props.drawingData
|
|
if (drawingData !== undefined) {
|
|
drawingData.id = oldId
|
|
}
|
|
modified = true
|
|
parseData()
|
|
}
|
|
if (draw.tool !== props.drawingTool) {
|
|
draw.tool = props.drawingTool ?? 'pen'
|
|
updateCanvasCursor()
|
|
}
|
|
if (draw.penColor !== props.penColor) {
|
|
draw.penColor = props.penColor ?? 'blue'
|
|
updateCanvasCursor()
|
|
}
|
|
if (props.readonly !== readonly) {
|
|
readonly = props.readonly ?? false
|
|
updateCanvasCursor()
|
|
}
|
|
},
|
|
destroy () {
|
|
if (props.saveDrawing === undefined) {
|
|
console.log('Save drawing method is not provided')
|
|
} else {
|
|
if (modified && (commands.length > 0 || drawingData?.id !== undefined)) {
|
|
const data: DrawingData = drawingData ?? {}
|
|
data.content = JSON.stringify(commands)
|
|
props.saveDrawing(data).catch((error) => {
|
|
console.error('Failed to save drawing', error)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|