Skip to content

Commit

Permalink
alex/state-node-prototype-2: controls
Browse files Browse the repository at this point in the history
  • Loading branch information
SomeHats committed Feb 28, 2024
1 parent fb5afbf commit e52f253
Show file tree
Hide file tree
Showing 19 changed files with 2,657 additions and 586 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { speechBubbleControl } from './SpeechBubble/SpeechBubbleHandle'
import {
DraggingSpeechBubble,
PointingSpeechBubble,
speechBubbleControl,
} from './SpeechBubble/SpeechBubbleHandle'
import { SpeechBubbleTool } from './SpeechBubble/SpeechBubbleTool'
import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil'
import { components, customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides'
Expand All @@ -18,6 +22,7 @@ export default function CustomShapeWithHandles() {
<div style={{ position: 'absolute', inset: 0 }}>
<Tldraw
onMount={(editor) => {
editor.root.find('select')!.addChild(DraggingSpeechBubble).addChild(PointingSpeechBubble)
editor.addControls(speechBubbleControl)
}}
shapeUtils={shapeUtils}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { Control, Editor } from '@tldraw/tldraw'
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '@tldraw/editor/src/lib/constants'
import {
TLClickEventInfo,
WithPreventDefault,
} from '@tldraw/editor/src/lib/editor/types/event-types'
import {
Circle2d,
Control,
ControlProps,
Editor,
SVGContainer,
StateNode,
TLPointerEventInfo,
Vec,
} from '@tldraw/tldraw'
import { SpeechBubbleShape } from './SpeechBubbleUtil'

export function speechBubbleControl(editor: Editor) {
// we only show the control in select.idle
if (!editor.isIn('select.idle')) return null
if (!editor.isInAny('select.idle', 'select.pointingSpeechBubble')) return null

// it's only relevant when we have a single speech bubble shape selected
const shape = editor.getOnlySelectedShape()
Expand All @@ -12,11 +26,148 @@ export function speechBubbleControl(editor: Editor) {
}

// return the control - this handles the actual interaction.
return new SpeechBubbleControl(shape)
return new SpeechBubbleControl(editor, shape)
}

class SpeechBubbleControl extends Control {
constructor(readonly shape: SpeechBubbleShape) {
super()
constructor(
editor: Editor,
readonly shape: SpeechBubbleShape
) {
super(editor, 'speech-bubble-handle')
}

override getGeometry() {
const radius = this.editor.getIsCoarsePointer() ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS
const tailInShapeSpace = {
x: this.shape.props.tail.x * this.shape.props.w,
y: this.shape.props.tail.y * this.shape.props.h,
}

const tailInPageSpace = this.editor
.getShapePageTransform(this.shape)
.applyToPoint(tailInShapeSpace)

return Circle2d.fromCenter({
...tailInPageSpace,
radius: radius / this.editor.getZoomLevel(),
isFilled: true,
})
}

override component({ isHovered }: ControlProps) {
const geom = this.getGeometry()
const zoom = this.editor.getZoomLevel()

return (
<SVGContainer style={{ zIndex: 'var(--layer-overlays)' }}>
{isHovered && (
<circle
cx={geom.center.x}
cy={geom.center.y}
r={geom.radius}
style={{
fill: 'var(--color-selection-fill)',
// is this how we should handle cursors? it's hard because this overlaps
// with the selection fg which uses css, but i'd rather a `getCursor`
// sort of API.
pointerEvents: 'all',
cursor: 'var(--tl-cursor-grab)',
}}
/>
)}
<circle className="tl-handle__fg" cx={geom.center.x} cy={geom.center.y} r={4 / zoom} />
</SVGContainer>
)
}

override onPointerDown(info: WithPreventDefault<TLPointerEventInfo>): void {
info.preventDefault()
this.editor.root.transition('select.pointingSpeechBubbleTail', this.shape)
}

override onDoubleClick(info: WithPreventDefault<TLClickEventInfo>): void {
if (info.phase !== 'up') return

info.preventDefault()
this.editor.updateShape({
id: this.shape.id,
type: 'speech-bubble',
props: {
tail: { x: 0.5, y: 1.5 },
},
})
}
}

export class PointingSpeechBubble extends StateNode {
override id = 'pointingSpeechBubbleTail'

shape!: SpeechBubbleShape

override onEnter = (shape: SpeechBubbleShape) => {
this.shape = shape
}

override onPointerMove = () => {
if (this.editor.inputs.isDragging) {
this.editor.root.transition('select.draggingSpeechBubbleTail', this.shape)
}
}

override onPointerUp = () => {
this.onCancel()
}

override onCancel = () => {
this.editor.root.transition('select.idle')
}
}

export class DraggingSpeechBubble extends StateNode {
override id = 'draggingSpeechBubbleTail'

initialShape!: SpeechBubbleShape
initialPageTailPoint!: Vec
markId!: string

// you can pass stuff to this i think? but it doesn't get typed. kinda tricky imo.
override onEnter = (shape: SpeechBubbleShape) => {
this.editor.mark('draggingSpeechBubbleTail')
this.initialShape = shape
const transform = this.editor.getShapePageTransform(shape)
this.initialPageTailPoint = transform.applyToPoint({
x: shape.props.tail.x * shape.props.w,
y: shape.props.tail.y * shape.props.h,
})
}

override onPointerMove = () => {
const currentShape = this.editor.getShape<SpeechBubbleShape>(this.initialShape.id)
if (!currentShape) return

const delta = Vec.Sub(this.editor.inputs.currentPagePoint, this.editor.inputs.originPagePoint)
const pageTailPoint = Vec.Add(this.initialPageTailPoint, delta)
const shapeTailPoint = this.editor.getPointInShapeSpace(this.initialShape.id, pageTailPoint)

this.editor.updateShape<SpeechBubbleShape>({
id: this.initialShape.id,
type: 'speech-bubble',
props: {
tail: {
x: shapeTailPoint.x / currentShape.props.w,
y: shapeTailPoint.y / currentShape.props.h,
},
},
})
}

override onPointerUp = () => {
this.editor.root.transition('select.idle')
}

override onCancel = () => {
this.editor.root.transition('select.idle')
this.editor.bailToMark('draggingSpeechBubbleTail')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ import {
TLBaseShape,
TLDefaultColorStyle,
TLDefaultSizeStyle,
TLHandle,
TLOnBeforeUpdateHandler,
TLOnHandleDragHandler,
TLOnResizeHandler,
Vec,
VecModel,
ZERO_INDEX_KEY,
deepCopy,
getDefaultColorTheme,
resizeBox,
Expand Down Expand Up @@ -83,34 +80,34 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
return body
}

// [4]
override getHandles(shape: SpeechBubbleShape): TLHandle[] {
const { tail, w, h } = shape.props

return [
{
id: 'tail',
type: 'vertex',
index: ZERO_INDEX_KEY,
// props.tail coordinates are normalized
// but here we need them in shape space
x: tail.x * w,
y: tail.y * h,
},
]
}

override onHandleDrag: TLOnHandleDragHandler<SpeechBubbleShape> = (shape, { handle }) => {
return {
...shape,
props: {
tail: {
x: handle.x / shape.props.w,
y: handle.y / shape.props.h,
},
},
}
}
// // [4]
// override getHandles(shape: SpeechBubbleShape): TLHandle[] {
// const { tail, w, h } = shape.props

// return [
// {
// id: 'tail',
// type: 'vertex',
// index: ZERO_INDEX_KEY,
// // props.tail coordinates are normalized
// // but here we need them in shape space
// x: tail.x * w,
// y: tail.y * h,
// },
// ]
// }

// override onHandleDrag: TLOnHandleDragHandler<SpeechBubbleShape> = (shape, { handle }) => {
// return {
// ...shape,
// props: {
// tail: {
// x: handle.x / shape.props.w,
// y: handle.y / shape.props.h,
// },
// },
// }
// }

// [5]
override onBeforeUpdate: TLOnBeforeUpdateHandler<SpeechBubbleShape> | undefined = (
Expand Down

0 comments on commit e52f253

Please sign in to comment.