497 lines
12 KiB
TypeScript
497 lines
12 KiB
TypeScript
import { DEFAULT_GESTURES, ALT_GESTURES } from "@/default_gestures"
|
|
import { DollarRecognizer } from "@/gestures"
|
|
import {
|
|
StateNode,
|
|
TLDefaultSizeStyle,
|
|
TLDrawShape,
|
|
TLDrawShapeSegment,
|
|
TLEventHandlers,
|
|
TLHighlightShape,
|
|
TLPointerEventInfo,
|
|
TLShapePartial,
|
|
TLTextShape,
|
|
Vec,
|
|
createShapeId,
|
|
uniqueId,
|
|
} from "tldraw"
|
|
|
|
const STROKE_WIDTH = 10
|
|
const SHOW_LABELS = true
|
|
const PRESSURE = 0.5
|
|
|
|
export class GestureTool extends StateNode {
|
|
static override id = "gesture"
|
|
static override initial = "idle"
|
|
static override children = () => [Idle, Drawing]
|
|
static recognizer = new DollarRecognizer(DEFAULT_GESTURES)
|
|
static recognizerAlt = new DollarRecognizer(ALT_GESTURES)
|
|
|
|
override shapeType = "draw"
|
|
|
|
override onExit = () => {
|
|
const drawingState = this.children!.drawing as Drawing
|
|
drawingState.initialShape = undefined
|
|
}
|
|
}
|
|
|
|
export class Idle extends StateNode {
|
|
static override id = "idle"
|
|
|
|
tooltipElement?: HTMLDivElement
|
|
tooltipTimeout?: NodeJS.Timeout
|
|
mouseMoveHandler?: (e: MouseEvent) => void
|
|
|
|
override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => {
|
|
this.parent.transition("drawing", info)
|
|
}
|
|
|
|
override onEnter = () => {
|
|
this.editor.setCursor({ type: "cross", rotation: 0 })
|
|
|
|
// Create tooltip element
|
|
this.tooltipElement = document.createElement('div')
|
|
this.tooltipElement.style.cssText = `
|
|
position: fixed;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
white-space: pre-line;
|
|
z-index: 10000;
|
|
pointer-events: none;
|
|
max-width: 300px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
`
|
|
|
|
// Set tooltip content
|
|
this.tooltipElement.innerHTML = `
|
|
<strong>Gesture Tool Active</strong><br><br>
|
|
<strong>Basic Gestures:</strong><br>
|
|
• X, Rectangle, Circle, Check<br>
|
|
• Caret, V, Delete, Pigtail<br><br>
|
|
<strong>Shift + Draw:</strong><br>
|
|
• Circle Layout, Triangle Layout<br><br>
|
|
Press 'g' again or select another tool to exit
|
|
`
|
|
|
|
// Add tooltip to DOM
|
|
document.body.appendChild(this.tooltipElement)
|
|
|
|
// Function to update tooltip position
|
|
this.mouseMoveHandler = (e: MouseEvent) => {
|
|
if (this.tooltipElement) {
|
|
const x = e.clientX + 20
|
|
const y = e.clientY - 20
|
|
|
|
// Keep tooltip within viewport bounds
|
|
const rect = this.tooltipElement.getBoundingClientRect()
|
|
const viewportWidth = window.innerWidth
|
|
const viewportHeight = window.innerHeight
|
|
|
|
let finalX = x
|
|
let finalY = y
|
|
|
|
// Adjust if tooltip would go off the right edge
|
|
if (x + rect.width > viewportWidth) {
|
|
finalX = e.clientX - rect.width - 20
|
|
}
|
|
|
|
// Adjust if tooltip would go off the bottom edge
|
|
if (y + rect.height > viewportHeight) {
|
|
finalY = e.clientY - rect.height - 20
|
|
}
|
|
|
|
// Ensure tooltip doesn't go off the top or left
|
|
finalX = Math.max(10, finalX)
|
|
finalY = Math.max(10, finalY)
|
|
|
|
this.tooltipElement.style.left = `${finalX}px`
|
|
this.tooltipElement.style.top = `${finalY}px`
|
|
}
|
|
}
|
|
|
|
// Add mouse move listener
|
|
document.addEventListener('mousemove', this.mouseMoveHandler)
|
|
|
|
// Set initial position
|
|
if (this.mouseMoveHandler) {
|
|
this.mouseMoveHandler({ clientX: 100, clientY: 100 } as MouseEvent)
|
|
}
|
|
|
|
// Remove the tooltip after 5 seconds
|
|
this.tooltipTimeout = setTimeout(() => {
|
|
this.cleanupTooltip()
|
|
}, 5000)
|
|
}
|
|
|
|
override onCancel = () => {
|
|
this.editor.setCurrentTool("select")
|
|
}
|
|
|
|
override onExit = () => {
|
|
this.cleanupTooltip()
|
|
}
|
|
|
|
private cleanupTooltip = () => {
|
|
// Clear timeout
|
|
if (this.tooltipTimeout) {
|
|
clearTimeout(this.tooltipTimeout)
|
|
this.tooltipTimeout = undefined
|
|
}
|
|
|
|
// Remove mouse move listener
|
|
if (this.mouseMoveHandler) {
|
|
document.removeEventListener('mousemove', this.mouseMoveHandler)
|
|
this.mouseMoveHandler = undefined
|
|
}
|
|
|
|
// Remove tooltip element
|
|
if (this.tooltipElement) {
|
|
document.body.removeChild(this.tooltipElement)
|
|
this.tooltipElement = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
type DrawableShape = TLDrawShape | TLHighlightShape
|
|
|
|
export class Drawing extends StateNode {
|
|
static override id = "drawing"
|
|
|
|
info = {} as TLPointerEventInfo
|
|
|
|
initialShape?: DrawableShape
|
|
|
|
override shapeType =
|
|
this.parent.id === "highlight" ? ("highlight" as const) : ("draw" as const)
|
|
|
|
util = this.editor.getShapeUtil(this.shapeType)
|
|
|
|
isPen = false
|
|
isPenOrStylus = false
|
|
|
|
didJustShiftClickToExtendPreviousShapeLine = false
|
|
|
|
pagePointWhereCurrentSegmentChanged = {} as Vec
|
|
|
|
pagePointWhereNextSegmentChanged = null as Vec | null
|
|
|
|
lastRecordedPoint = {} as Vec
|
|
mergeNextPoint = false
|
|
currentLineLength = 0
|
|
|
|
canDraw = false
|
|
|
|
markId = null as null | string
|
|
|
|
override onEnter = (info: TLPointerEventInfo) => {
|
|
this.markId = null
|
|
this.info = info
|
|
this.canDraw = !this.editor.getIsMenuOpen()
|
|
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
|
|
if (this.canDraw) {
|
|
this.startShape()
|
|
}
|
|
}
|
|
|
|
onGestureEnd = () => {
|
|
const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape
|
|
const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y }))
|
|
const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps)
|
|
const score_pass = gesture.score > 0.2
|
|
const score_confident = gesture.score > 0.65
|
|
let score_color: "green" | "red" | "yellow" = "green"
|
|
if (!score_pass) {
|
|
score_color = "red"
|
|
} else if (!score_confident) {
|
|
score_color = "yellow"
|
|
}
|
|
if (score_pass) {
|
|
gesture.onComplete?.(this.editor, shape)
|
|
}
|
|
let opacity = 1
|
|
const labelShape: TLShapePartial<TLTextShape> = {
|
|
id: createShapeId(),
|
|
type: "text",
|
|
x: this.editor.inputs.currentPagePoint.x + 20,
|
|
y: this.editor.inputs.currentPagePoint.y,
|
|
props: {
|
|
size: "xl",
|
|
text: gesture.name,
|
|
color: score_color,
|
|
},
|
|
}
|
|
if (SHOW_LABELS) {
|
|
this.editor.createShape(labelShape)
|
|
}
|
|
const intervalId = setInterval(() => {
|
|
if (opacity > 0) {
|
|
this.editor.updateShape({
|
|
...shape,
|
|
opacity: opacity,
|
|
props: {
|
|
...shape.props,
|
|
color: score_color,
|
|
},
|
|
})
|
|
this.editor.updateShape({
|
|
...labelShape,
|
|
opacity: opacity,
|
|
props: {
|
|
...labelShape.props,
|
|
color: score_color,
|
|
},
|
|
})
|
|
opacity = Math.max(0, opacity - 0.025)
|
|
} else {
|
|
clearInterval(intervalId)
|
|
this.editor.deleteShape(shape.id)
|
|
if (SHOW_LABELS) {
|
|
this.editor.deleteShape(labelShape.id)
|
|
}
|
|
}
|
|
}, 20)
|
|
}
|
|
|
|
override onPointerMove: TLEventHandlers["onPointerMove"] = () => {
|
|
const { inputs } = this.editor
|
|
|
|
if (this.isPen && !inputs.isPen) {
|
|
// The user made a palm gesture before starting a pen gesture;
|
|
// ideally we'd start the new shape here but we could also just bail
|
|
// as the next interaction will work correctly
|
|
if (this.markId) {
|
|
this.editor.bailToMark(this.markId)
|
|
this.startShape()
|
|
return
|
|
}
|
|
} else {
|
|
// If we came in from a menu but have no started dragging...
|
|
if (!this.canDraw && inputs.isDragging) {
|
|
this.startShape()
|
|
this.canDraw = true // bad name
|
|
}
|
|
}
|
|
|
|
if (this.canDraw) {
|
|
if (this.isPenOrStylus) {
|
|
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
|
|
if (
|
|
Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >=
|
|
1 / this.editor.getZoomLevel()
|
|
) {
|
|
this.lastRecordedPoint = inputs.currentPagePoint.clone()
|
|
this.mergeNextPoint = false
|
|
} else {
|
|
this.mergeNextPoint = true
|
|
}
|
|
} else {
|
|
this.mergeNextPoint = false
|
|
}
|
|
|
|
this.updateDrawingShape()
|
|
}
|
|
}
|
|
|
|
override onExit? = () => {
|
|
this.onGestureEnd()
|
|
this.editor.snaps.clearIndicators()
|
|
this.pagePointWhereCurrentSegmentChanged =
|
|
this.editor.inputs.currentPagePoint.clone()
|
|
}
|
|
|
|
canClose() {
|
|
return this.shapeType !== "highlight"
|
|
}
|
|
|
|
getIsClosed(segments: TLDrawShapeSegment[]) {
|
|
if (!this.canClose()) return false
|
|
|
|
const strokeWidth = STROKE_WIDTH
|
|
const firstPoint = segments[0].points[0]
|
|
const lastSegment = segments[segments.length - 1]
|
|
const lastPoint = lastSegment.points[lastSegment.points.length - 1]
|
|
|
|
return (
|
|
firstPoint !== lastPoint &&
|
|
this.currentLineLength > strokeWidth * 4 &&
|
|
Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2)
|
|
)
|
|
}
|
|
|
|
private startShape() {
|
|
const {
|
|
inputs: { originPagePoint },
|
|
} = this.editor
|
|
|
|
this.markId = this.editor.markHistoryStoppingPoint()
|
|
|
|
this.didJustShiftClickToExtendPreviousShapeLine = false
|
|
|
|
this.lastRecordedPoint = originPagePoint.clone()
|
|
|
|
this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone()
|
|
const id = createShapeId()
|
|
|
|
this.editor.createShapes<DrawableShape>([
|
|
{
|
|
id,
|
|
type: this.shapeType,
|
|
x: originPagePoint.x,
|
|
y: originPagePoint.y,
|
|
opacity: 0.5,
|
|
props: {
|
|
isPen: this.isPenOrStylus,
|
|
segments: [
|
|
{
|
|
type: "free",
|
|
points: [
|
|
{
|
|
x: 0,
|
|
y: 0,
|
|
z: PRESSURE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
])
|
|
this.currentLineLength = 0
|
|
this.initialShape = this.editor.getShape<DrawableShape>(id)
|
|
}
|
|
|
|
private updateDrawingShape() {
|
|
const { initialShape } = this
|
|
const { inputs } = this.editor
|
|
|
|
if (!initialShape) return
|
|
|
|
const {
|
|
id,
|
|
} = initialShape
|
|
|
|
const shape = this.editor.getShape<DrawableShape>(id)!
|
|
|
|
if (!shape) return
|
|
|
|
const { segments } = shape.props
|
|
|
|
const { x, y, z } = this.editor
|
|
.getPointInShapeSpace(shape, inputs.currentPagePoint)
|
|
.toFixed()
|
|
|
|
const newPoint = {
|
|
x,
|
|
y,
|
|
z: this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5,
|
|
}
|
|
|
|
const newSegments = segments.slice()
|
|
const newSegment = newSegments[newSegments.length - 1]
|
|
const newPoints = [...newSegment.points]
|
|
|
|
if (newPoints.length && this.mergeNextPoint) {
|
|
const { z } = newPoints[newPoints.length - 1]
|
|
newPoints[newPoints.length - 1] = {
|
|
x: newPoint.x,
|
|
y: newPoint.y,
|
|
z: z ? Math.max(z, newPoint.z) : newPoint.z,
|
|
}
|
|
} else {
|
|
this.currentLineLength += Vec.Dist(
|
|
newPoints[newPoints.length - 1],
|
|
newPoint,
|
|
)
|
|
newPoints.push(newPoint)
|
|
}
|
|
|
|
newSegments[newSegments.length - 1] = {
|
|
...newSegment,
|
|
points: newPoints,
|
|
}
|
|
|
|
if (this.currentLineLength < STROKE_WIDTH * 4) {
|
|
this.currentLineLength = this.getLineLength(newSegments)
|
|
}
|
|
|
|
const shapePartial: TLShapePartial<DrawableShape> = {
|
|
id,
|
|
type: this.shapeType,
|
|
props: {
|
|
segments: newSegments,
|
|
},
|
|
}
|
|
|
|
if (this.canClose()) {
|
|
; (shapePartial as TLShapePartial<TLDrawShape>).props!.isClosed =
|
|
this.getIsClosed(newSegments)
|
|
}
|
|
|
|
this.editor.updateShapes([shapePartial])
|
|
}
|
|
|
|
private getLineLength(segments: TLDrawShapeSegment[]) {
|
|
let length = 0
|
|
|
|
for (const segment of segments) {
|
|
for (let i = 0; i < segment.points.length - 1; i++) {
|
|
const A = segment.points[i]
|
|
const B = segment.points[i + 1]
|
|
length += Vec.Dist2(B, A)
|
|
}
|
|
}
|
|
|
|
return Math.sqrt(length)
|
|
}
|
|
|
|
override onPointerUp: TLEventHandlers["onPointerUp"] = () => {
|
|
this.complete()
|
|
}
|
|
|
|
override onCancel: TLEventHandlers["onCancel"] = () => {
|
|
this.cancel()
|
|
}
|
|
|
|
override onComplete: TLEventHandlers["onComplete"] = () => {
|
|
this.complete()
|
|
}
|
|
|
|
override onInterrupt: TLEventHandlers["onInterrupt"] = () => {
|
|
if (this.editor.inputs.isDragging) {
|
|
return
|
|
}
|
|
|
|
if (this.markId) {
|
|
this.editor.bailToMark(this.markId)
|
|
}
|
|
this.cancel()
|
|
}
|
|
|
|
complete() {
|
|
if (!this.canDraw) {
|
|
this.cancel()
|
|
return
|
|
}
|
|
|
|
const { initialShape } = this
|
|
if (!initialShape) return
|
|
this.editor.updateShapes([
|
|
{
|
|
id: initialShape.id,
|
|
type: initialShape.type,
|
|
props: { isComplete: true },
|
|
},
|
|
])
|
|
|
|
this.parent.transition("idle")
|
|
}
|
|
|
|
cancel() {
|
|
this.parent.transition("idle", this.info)
|
|
}
|
|
} |