add triangle tool (#433)

* add triangle tool

* fix keyboard shortcuts

* cleaned code

* Add binding, better indicator, bounds

* Fix tests

* Refactor getBindingPoint, binding distances, add comments to getBindingPoint

* Update TextUtil.spec.tsx.snap

* fix intersection math

* fix ellipse indicator

* Update EllipseUtil.tsx

* Update BrushSession.spec.ts

* Add draw style to triangle

* improve strokes

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Elizabeth Louie 2021-12-09 17:29:09 -05:00 committed by GitHub
parent 86c651764c
commit c5124b160e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 918 additions and 139 deletions

View file

@ -91,4 +91,4 @@
}
},
"gitHead": "3ab5db27b9e83736fdae934474e80e90c854922c"
}
}

View file

@ -71,6 +71,7 @@ const defaultTheme: TLTheme = {
brushStroke: 'rgba(0,0,0,.25)',
selectStroke: 'rgb(66, 133, 244)',
selectFill: 'rgba(65, 132, 244, 0.05)',
binding: 'rgba(65, 132, 244, 0.12)',
background: 'rgb(248, 249, 250)',
foreground: 'rgb(51, 51, 51)',
grid: 'rgba(144, 144, 144, 1)',
@ -408,10 +409,8 @@ const tlcss = css`
}
.tl-binding-indicator {
stroke-width: calc(3px * var(--tl-scale));
fill: var(--tl-selectFill);
stroke: var(--tl-selected);
pointer-events: none;
fill: transparent;
stroke: var(--tl-binding);
}
.tl-centered-g {

View file

@ -106,6 +106,7 @@ export interface TLTheme {
brushStroke?: string
selectFill?: string
selectStroke?: string
binding: string
background?: string
foreground?: string
grid?: string

View file

@ -148,6 +148,13 @@ export class Utils {
/* ---------------------- Boxes --------------------- */
static pointsToLineSegments(points: number[][], closed = false) {
const segments = []
for (let i = 1; i < points.length; i++) segments.push([points[i - 1], points[i]])
if (closed) segments.push([points[points.length - 1], points[0]])
return segments
}
static getRectangleSides(point: number[], size: number[], rotation = 0): [string, number[][]][] {
const center = [point[0] + size[0] / 2, point[1] + size[1] / 2]
const tl = Vec.rotWith(point, center, rotation)

View file

@ -64,6 +64,30 @@ function isAngleBetween(a: number, b: number, c: number): boolean {
return AB <= Math.PI !== AC > AB
}
/* -------------------------------------------------- */
/* Line */
/* -------------------------------------------------- */
export function intersectLineLine(AB: number[][], PQ: number[][]): number[] | undefined {
const slopeAB = Vec.slope(AB[0], AB[1])
const slopePQ = Vec.slope(PQ[0], PQ[1])
if (slopeAB === slopePQ) return undefined
if (Number.isNaN(slopeAB) && !Number.isNaN(slopePQ)) {
return [AB[0][0], (AB[0][0] - PQ[0][0]) * slopePQ + PQ[0][1]]
}
if (Number.isNaN(slopePQ) && !Number.isNaN(slopeAB)) {
return [PQ[0][0], (PQ[0][0] - AB[0][0]) * slopeAB + AB[0][1]]
}
const x = (slopeAB * AB[0][0] - slopePQ * PQ[0][0] + PQ[0][1] - AB[0][1]) / (slopeAB - slopePQ)
const y = slopePQ * (x - PQ[0][0]) + PQ[0][1]
return [x, y]
}
/* -------------------------------------------------- */
/* Ray */
/* -------------------------------------------------- */
@ -1239,3 +1263,39 @@ export function intersectPolygonBounds(points: number[][], bounds: TLBounds): TL
points
)
}
/**
* Find the intersections between a rectangle and a ray.
* @param point
* @param size
* @param rotation
* @param origin
* @param direction
*/
export function intersectRayPolygon(
origin: number[],
direction: number[],
points: number[][]
): TLIntersection[] {
const sideIntersections = pointsToLineSegments(points, true).reduce<TLIntersection[]>(
(acc, [a1, a2], i) => {
const intersection = intersectRayLineSegment(origin, direction, a1, a2)
if (intersection) {
acc.push(createIntersection(i.toString(), ...intersection.points))
}
return acc
},
[]
)
return sideIntersections.filter((int) => int.didIntersect)
}
export function pointsToLineSegments(points: number[][], closed = false) {
const segments = []
for (let i = 1; i < points.length; i++) segments.push([points[i - 1], points[i]])
if (closed) segments.push([points[points.length - 1], points[0]])
return segments
}

View file

@ -75,7 +75,7 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
</ToolButtonWithTooltip>
<ShapesMenu activeTool={activeTool} isToolLocked={isToolLocked} />
<ToolButtonWithTooltip
kbd={'7'}
kbd={'8'}
label={TDShapeType.Arrow}
onClick={selectArrowTool}
isLocked={isToolLocked}
@ -84,7 +84,7 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
<ArrowTopRightIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'8'}
kbd={'9'}
label={TDShapeType.Text}
onClick={selectTextTool}
isLocked={isToolLocked}
@ -93,7 +93,7 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element {
<TextIcon />
</ToolButtonWithTooltip>
<ToolButtonWithTooltip
kbd={'9'}
kbd={'0'}
label={TDShapeType.Sticky}
onClick={selectStickyTool}
isActive={activeTool === TDShapeType.Sticky}

View file

@ -4,7 +4,7 @@ import { Panel } from '~components/Primitives/Panel'
import { ToolButton } from '~components/Primitives/ToolButton'
import { TDShapeType, TDToolType } from '~types'
import { useTldrawApp } from '~hooks'
import { SquareIcon, CircleIcon } from '@radix-ui/react-icons'
import { SquareIcon, CircleIcon, VercelLogoIcon } from '@radix-ui/react-icons'
import { Tooltip } from '~components/Primitives/Tooltip'
import { LineIcon } from '~components/Primitives/icons'
@ -13,11 +13,12 @@ interface ShapesMenuProps {
isToolLocked: boolean
}
type ShapeShape = TDShapeType.Rectangle | TDShapeType.Ellipse | TDShapeType.Line
const shapeShapes: ShapeShape[] = [TDShapeType.Rectangle, TDShapeType.Ellipse, TDShapeType.Line]
type ShapeShape = TDShapeType.Rectangle | TDShapeType.Ellipse | TDShapeType.Triangle | TDShapeType.Line
const shapeShapes: ShapeShape[] = [TDShapeType.Rectangle, TDShapeType.Ellipse, TDShapeType.Triangle, TDShapeType.Line]
const shapeShapeIcons = {
[TDShapeType.Rectangle]: <SquareIcon />,
[TDShapeType.Ellipse]: <CircleIcon />,
[TDShapeType.Triangle]: <VercelLogoIcon />,
[TDShapeType.Line]: <LineIcon />,
}

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const GRID_SIZE = 8
export const BINDING_DISTANCE = 24
export const BINDING_DISTANCE = 16
export const CLONING_DISTANCE = 32
export const FIT_TO_SCREEN_PADDING = 128
export const SNAP_DISTANCE = 5

View file

@ -67,7 +67,17 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
)
useHotkeys(
'l,6',
'g,6',
() => {
if (!canHandleEvent()) return
app.selectTool(TDShapeType.Triangle)
},
undefined,
[app]
)
useHotkeys(
'l,7',
() => {
if (!canHandleEvent(true)) return
app.selectTool(TDShapeType.Line)
@ -77,7 +87,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
)
useHotkeys(
'a,7',
'a,8',
() => {
if (!canHandleEvent(true)) return
app.selectTool(TDShapeType.Arrow)
@ -87,7 +97,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
)
useHotkeys(
't,8',
't,9',
() => {
if (!canHandleEvent(true)) return
app.selectTool(TDShapeType.Text)
@ -97,7 +107,7 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
)
useHotkeys(
's,9',
's,0',
() => {
if (!canHandleEvent(true)) return
app.selectTool(TDShapeType.Sticky)

View file

@ -634,6 +634,7 @@ export class TLDR {
binding,
otherShape,
TLDR.getShapeUtil(otherShape).getBounds(otherShape),
TLDR.getShapeUtil(otherShape).getExpandedBounds(otherShape),
TLDR.getShapeUtil(otherShape).getCenter(otherShape)
)
if (!delta) return shape
@ -866,8 +867,8 @@ export class TLDR {
return shapes.length === 0
? 1
: shapes
.filter((shape) => shape.parentId === pageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
.filter((shape) => shape.parentId === pageId)
.sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1
}
/* -------------------------------------------------- */
@ -895,7 +896,7 @@ export class TLDR {
static warn(e: any) {
if (isDev) {
console.warn(e);
console.warn(e)
}
}
static error(e: any) {
@ -903,5 +904,4 @@ export class TLDR {
console.error(e)
}
}
}

View file

@ -55,6 +55,7 @@ import { TextTool } from './tools/TextTool'
import { DrawTool } from './tools/DrawTool'
import { EllipseTool } from './tools/EllipseTool'
import { RectangleTool } from './tools/RectangleTool'
import { TriangleTool } from './tools/TriangleTool'
import { LineTool } from './tools/LineTool'
import { ArrowTool } from './tools/ArrowTool'
import { StickyTool } from './tools/StickyTool'
@ -139,6 +140,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
[TDShapeType.Draw]: new DrawTool(this),
[TDShapeType.Ellipse]: new EllipseTool(this),
[TDShapeType.Rectangle]: new RectangleTool(this),
[TDShapeType.Triangle]: new TriangleTool(this),
[TDShapeType.Line]: new LineTool(this),
[TDShapeType.Arrow]: new ArrowTool(this),
[TDShapeType.Sticky]: new StickyTool(this),
@ -343,6 +345,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
binding,
toShape,
toUtils.getBounds(toShape),
toUtils.getExpandedBounds(toShape),
toUtils.getCenter(toShape)
)
@ -735,6 +738,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
binding,
toShape,
toUtils.getBounds(toShape),
toUtils.getExpandedBounds(toShape),
toUtils.getCenter(toShape)
)

View file

@ -80,7 +80,7 @@ describe('Arrow session', () => {
.select('arrow1')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([124, -24])
.movePointer([116, -16])
expect(app.bindings[0].point).toStrictEqual([1, 0])
})
@ -103,7 +103,7 @@ describe('Arrow session', () => {
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([91, 9])
expect(app.bindings[0].point).toStrictEqual([0.71, 0.11])
expect(app.bindings[0].point).toMatchSnapshot()
app.movePointer({ x: 91, y: 9, altKey: true })
})
@ -116,7 +116,7 @@ describe('Arrow session', () => {
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer({ x: 91, y: 9, altKey: true })
expect(app.bindings[0].point).toStrictEqual([0.78, 0.22])
expect(app.bindings[0].point).toMatchSnapshot()
})
it('ignores binding when meta is held', () => {
@ -269,8 +269,8 @@ describe('When drawing an arrow', () => {
size: [200, 200],
})
.selectTool(TDShapeType.Arrow)
.pointCanvas([75, 100])
.movePointer([76, 100]) // One pixel right, into binding area
.pointCanvas([84, 100])
.movePointer([85, 100]) // One pixel right, into binding area
.stopPointing()
expect(app.shapes.length).toBe(2)
@ -285,8 +285,8 @@ describe('When drawing an arrow', () => {
size: [200, 200],
})
.selectTool(TDShapeType.Arrow)
.pointCanvas([75, 100])
.movePointer([74, 100]) // One pixel left, not in binding area
.pointCanvas([84, 100])
.movePointer([83, 100]) // One pixel left, not in binding area
.stopPointing()
expect(app.shapes.length).toBe(1)

View file

@ -11,7 +11,6 @@ import {
} from '~types'
import { Vec } from '@tldraw/vec'
import { TLDR } from '~state/TLDR'
import { BINDING_DISTANCE } from '~constants'
import { shapeUtils } from '~state/shapes'
import { BaseSession } from '../BaseSession'
import type { TldrawApp } from '../../internal'
@ -186,6 +185,7 @@ export class ArrowSession extends BaseSession {
startBinding,
target,
targetUtils.getBounds(target),
targetUtils.getExpandedBounds(target),
targetUtils.getCenter(target)
)
@ -264,6 +264,7 @@ export class ArrowSession extends BaseSession {
draggedBinding,
target,
targetUtils.getBounds(target),
targetUtils.getExpandedBounds(target),
targetUtils.getCenter(target)
)
@ -436,15 +437,7 @@ export class ArrowSession extends BaseSession {
) => {
const util = TLDR.getShapeUtil<TDShape>(target.type)
const bindingPoint = util.getBindingPoint(
target,
shape,
point,
origin,
direction,
BINDING_DISTANCE,
bindAnywhere
)
const bindingPoint = util.getBindingPoint(target, shape, point, origin, direction, bindAnywhere)
// Not all shapes will produce a binding point
if (!bindingPoint) return

View file

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Arrow session arrow binding binds on the inside of a shape while alt is held 1`] = `
Array [
0.76,
0.09,
]
`;
exports[`Arrow session arrow binding snaps to the inside center when the point is close to the center 1`] = `
Array [
0.81,
0.19,
]
`;

View file

@ -11,7 +11,7 @@ describe('Brush session', () => {
.movePointer([48, 48])
.completeSession()
expect(app.status).toBe(TDStatus.Idle)
expect(app.selectedIds.length).toBe(1)
expect(app.selectedIds.length).toBe(2)
})
it('selects multiple shapes', () => {

View file

@ -20,6 +20,7 @@ import {
intersectLineSegmentLineSegment,
intersectRayBounds,
intersectRayEllipse,
intersectRayLineSegment,
} from '@tldraw/intersect'
import { BINDING_DISTANCE, EASINGS, GHOSTED_OPACITY } from '~constants'
import {
@ -35,6 +36,7 @@ import {
renderCurvedFreehandArrowShaft,
renderFreehandArrowShaft,
} from './arrowHelpers'
import { getTrianglePoints } from '../TriangleUtil'
type T = ArrowShape
type E = SVGSVGElement
@ -436,15 +438,12 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
binding: TDBinding,
target: TDShape,
targetBounds: TLBounds,
expandedBounds: TLBounds,
center: number[]
): Partial<T> | void => {
const handle = shape.handles[binding.handleId as keyof ArrowShape['handles']]
const expandedBounds = Utils.expandBounds(targetBounds, BINDING_DISTANCE)
// The anchor is the "actual" point in the target shape
// (Remember that the binding.point is normalized)
const anchor = Vec.sub(
let handlePoint = Vec.sub(
Vec.add(
[expandedBounds.minX, expandedBounds.minY],
Vec.mulV(
@ -455,9 +454,6 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
shape.point
)
// We're looking for the point to put the dragging handle
let handlePoint = anchor
if (binding.distance) {
const intersectBounds = Utils.expandBounds(targetBounds, binding.distance)
@ -468,7 +464,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
)
// And passes through the dragging handle
const direction = Vec.uni(Vec.sub(Vec.add(anchor, shape.point), origin))
const direction = Vec.uni(Vec.sub(Vec.add(handlePoint, shape.point), origin))
if (target.type === TDShapeType.Ellipse) {
const hits = intersectRayEllipse(
@ -480,6 +476,22 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
target.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}
} else if (target.type === TDShapeType.Triangle) {
const points = getTrianglePoints(target, BINDING_DISTANCE).map((pt) =>
Vec.add(pt, target.point)
)
const segments = Utils.pointsToLineSegments(points, true)
const hits = segments
.map((segment) => intersectRayLineSegment(origin, direction, segment[0], segment[1]))
.filter((intersection) => intersection.didIntersect)
.flatMap((intersection) => intersection.points)
.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))
if (hits[0]) {
handlePoint = Vec.sub(hits[0], shape.point)
}

View file

@ -64,8 +64,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
rx={rx + 2}
ry={ry + 2}
rx={rx}
ry={ry}
strokeWidth={this.bindingDistance}
/>
)}
<ellipse
@ -95,9 +96,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
)
}
const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2)
const perimeter = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
const perimeter = Utils.perimeterOfEllipse(rx, ry) // Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
@ -113,8 +112,9 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
rx={rx + 32}
ry={ry + 32}
rx={rx}
ry={ry}
strokeWidth={this.bindingDistance}
/>
)}
<ellipse
@ -146,13 +146,19 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
const {
radius: [radiusX, radiusY],
style: { dash },
style,
} = shape
return dash === DashStyle.Draw ? (
const styles = getShapeStyle(style)
const strokeWidth = styles.strokeWidth
const sw = 1 + strokeWidth * 1.618
const rx = Math.max(0, radiusX - sw / 2)
const ry = Math.max(0, radiusY - sw / 2)
return style.dash === DashStyle.Draw ? (
<path d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))} />
) : (
<ellipse cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} />
<ellipse cx={radiusX} cy={radiusY} rx={rx} ry={ry} />
)
})
@ -231,13 +237,10 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
point: number[],
origin: number[],
direction: number[],
padding: number,
bindAnywhere: boolean
) => {
{
const bounds = this.getBounds(shape)
const expandedBounds = Utils.expandBounds(bounds, padding)
const expandedBounds = this.getExpandedBounds(shape)
const center = this.getCenter(shape)
@ -248,8 +251,8 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
!Utils.pointInEllipse(
point,
center,
shape.radius[0] + BINDING_DISTANCE,
shape.radius[1] + BINDING_DISTANCE
shape.radius[0] + this.bindingDistance,
shape.radius[1] + this.bindingDistance
)
)
return
@ -308,7 +311,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0)
) {
// Pad the arrow out by 16 points
distance = BINDING_DISTANCE / 2
distance = this.bindingDistance / 2
} else {
// Find the distance between the point and the ellipse
const innerIntersection = intersectLineSegmentEllipse(
@ -324,7 +327,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
return undefined
}
distance = Math.max(BINDING_DISTANCE / 2, Vec.dist(point, innerIntersection))
distance = Math.max(this.bindingDistance / 2, Vec.dist(point, innerIntersection))
}
}

View file

@ -68,7 +68,7 @@ export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
return Utils.getSvgPathFromStroke(
getStrokeOutlinePoints(getEllipseStrokePoints(shape, boundsCenter), {
size: 1 + strokeWidth * 2,
size: 2 + strokeWidth * 2,
thinning: 0.618,
end: { taper: perimeter / 8 },
start: { taper: perimeter / 12 },

View file

@ -3,7 +3,7 @@ import { styled } from '~styles'
import { Utils, SVGContainer } from '@tldraw/core'
import { defaultStyle } from '../shared/shape-styles'
import { TDShapeType, GroupShape, ColorStyle, TDMeta } from '~types'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { GHOSTED_OPACITY } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import { getBoundsRectangle } from '../shared'
@ -55,13 +55,7 @@ export class GroupUtil extends TDShapeUtil<T, E> {
return (
<SVGContainer ref={ref} {...events}>
{isBinding && (
<rect
className="tl-binding-indicator"
x={-BINDING_DISTANCE}
y={-BINDING_DISTANCE}
width={size[0] + BINDING_DISTANCE * 2}
height={size[1] + BINDING_DISTANCE * 2}
/>
<rect className="tl-binding-indicator" strokeWidth={this.bindingDistance} />
)}
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
<rect

View file

@ -3,7 +3,7 @@ import { Utils, SVGContainer } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { getStroke, getStrokePoints } from 'perfect-freehand'
import { RectangleShape, DashStyle, TDShapeType, TDMeta } from '~types'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { GHOSTED_OPACITY } from '~constants'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
@ -57,10 +57,11 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
{isBinding && (
<rect
className="tl-binding-indicator"
x={strokeWidth / 2 - BINDING_DISTANCE}
y={strokeWidth / 2 - BINDING_DISTANCE}
width={Math.max(0, size[0] - strokeWidth / 2) + BINDING_DISTANCE * 2}
height={Math.max(0, size[1] - strokeWidth / 2) + BINDING_DISTANCE * 2}
x={strokeWidth}
y={strokeWidth}
width={Math.max(0, size[0] - strokeWidth / 2)}
height={Math.max(0, size[1] - strokeWidth / 2)}
strokeWidth={this.bindingDistance * 2}
/>
)}
<path
@ -129,11 +130,12 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
/>
)}
<rect
className={isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
x={sw / 2}
y={sw / 2}
width={w}
height={h}
className="tl-binding-indicator"
x={sw / 2 - 32}
y={sw / 2 - 32}
width={w + 64}
height={h + 64}
strokeWidth={this.bindingDistance}
/>
{style.isFilled && (
<rect

View file

@ -48,7 +48,7 @@ export class StickyUtil extends TDShapeUtil<T, E> {
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, meta, events, isGhost, isEditing, onShapeBlur, onShapeChange }, ref) => {
({ shape, meta, events, isGhost, isBinding, isEditing, onShapeBlur, onShapeChange }, ref) => {
const font = getStickyFontStyle(shape.style)
const { color, fill } = getStickyShapeStyle(shape.style, meta.isDarkMode)
@ -191,6 +191,19 @@ export class StickyUtil extends TDShapeUtil<T, E> {
isGhost={isGhost}
style={{ backgroundColor: fill, ...style }}
>
{isBinding && (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
top: -this.bindingDistance,
left: -this.bindingDistance,
width: `calc(100% + ${this.bindingDistance * 2}px)`,
height: `calc(100% + ${this.bindingDistance * 2}px)`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
)}
<StyledText ref={rText} isEditing={isEditing} alignment={shape.style.textAlign}>
{shape.text}&#8203;
</StyledText>

View file

@ -10,6 +10,7 @@ import {
import { Vec } from '@tldraw/vec'
import type { TDBinding, TDMeta, TDShape, TransformInfo } from '~types'
import * as React from 'react'
import { BINDING_DISTANCE } from '~constants'
export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> extends TLShapeUtil<
T,
@ -28,6 +29,8 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
hideResizeHandles = false
bindingDistance = BINDING_DISTANCE
abstract getShape: (props: Partial<T>) => T
hitTestPoint = (shape: T, point: number[]): boolean => {
@ -53,74 +56,68 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
return Utils.getBoundsCenter(this.getBounds(shape))
}
getExpandedBounds = (shape: T) => {
return Utils.expandBounds(this.getBounds(shape), this.bindingDistance)
}
getBindingPoint = <K extends TDShape>(
shape: T,
fromShape: K,
point: number[],
origin: number[],
direction: number[],
padding: number,
bindAnywhere: boolean
) => {
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
let bindingPoint: number[]
let distance: number
const bounds = this.getBounds(shape)
const expandedBounds = Utils.expandBounds(bounds, padding)
const expandedBounds = this.getExpandedBounds(shape)
// The point must be inside of the expanded bounding box
if (!Utils.pointInBounds(point, expandedBounds)) return
// The point is inside of the shape, so we'll assume the user is indicating a specific point inside of the shape.
if (bindAnywhere) {
if (Vec.dist(point, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
} else {
bindingPoint = Vec.divV(Vec.sub(point, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
const intersections = intersectRayBounds(origin, direction, expandedBounds)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
if (!intersections.length) return
// The center of the shape
const center = this.getCenter(shape)
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
const intersection = intersections.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
// The point between the handle and the intersection
const middlePoint = Vec.med(point, intersection)
// The anchor is the point in the shape where the arrow will be pointing
let anchor: number[]
// The distance is the distance from the anchor to the handle
let distance: number
if (bindAnywhere) {
// If the user is indicating that they want to bind inside of the shape, we just use the handle's point
anchor = Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point
distance = 0
} else {
// (1) Binding point
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
const intersection = intersectRayBounds(origin, direction, expandedBounds)
.filter((int) => int.didIntersect)
.map((int) => int.points[0])
.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
// The anchor is a point between the handle and the intersection
const anchor = Vec.med(point, intersection)
// If we're close to the center, snap to the center, or else calculate a normalized point based on the anchor and the expanded bounds.
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
if (Vec.distanceToLineSegment(point, middlePoint, center) < BINDING_DISTANCE / 2) {
// If the line segment would pass near to the center, snap the anchor the center point
anchor = center
} else {
//
bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
// Otherwise, the anchor is the middle point between the handle and the intersection
anchor = middlePoint
}
// (3) Distance
// If the point is inside of the bounds, set the distance to a fixed value.
if (Utils.pointInBounds(point, bounds)) {
distance = 16
// If the point is inside of the shape, use the shape's binding distance
distance = this.bindingDistance
} else {
// If the binding point was close to the shape's center, snap to to the center. Find the distance between the point and the real bounds of the shape
// Otherwise, use the actual distance from the handle point to nearest edge
distance = Math.max(
16,
this.bindingDistance,
Utils.getBoundsSides(bounds)
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
.sort((a, b) => a - b)[0]
@ -128,6 +125,15 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
}
}
// The binding point is a normalized point indicating the position of the anchor.
// An anchor at the middle of the shape would be (0.5, 0.5). When the shape's bounds
// changes, we will re-recalculate the actual anchor point by multiplying the
// normalized point by the shape's new bounds.
const bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
return {
point: Vec.clampV(bindingPoint, 0, 1),
distance,
@ -155,6 +161,7 @@ export abstract class TDShapeUtil<T extends TDShape, E extends Element = any> ex
binding: TDBinding,
target: TDShape,
targetBounds: TLBounds,
expandedBounds: TLBounds,
center: number[]
) => Partial<T> | void

View file

@ -27,6 +27,8 @@ export class TextUtil extends TDShapeUtil<T, E> {
canClone = true
bindingDistance = BINDING_DISTANCE / 2
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
@ -191,10 +193,10 @@ export class TextUtil extends TDShapeUtil<T, E> {
className="tl-binding-indicator"
style={{
position: 'absolute',
top: -BINDING_DISTANCE,
left: -BINDING_DISTANCE,
width: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
height: `calc(100% + ${BINDING_DISTANCE * 2}px)`,
top: -this.bindingDistance,
left: -this.bindingDistance,
width: `calc(100% + ${this.bindingDistance * 2}px)`,
height: `calc(100% + ${this.bindingDistance * 2}px)`,
backgroundColor: 'var(--tl-selectFill)',
}}
/>
@ -205,7 +207,6 @@ export class TextUtil extends TDShapeUtil<T, E> {
style={{
font,
color: styles.stroke,
textAlign: 'inherit',
}}
name="text"
defaultValue={text}
@ -426,7 +427,7 @@ const InnerWrapper = styled('div', {
zIndex: 1,
minHeight: 1,
minWidth: 1,
lineHeight: 1.4,
lineHeight: 1,
letterSpacing: LETTER_SPACING,
outline: 0,
fontWeight: '500',
@ -457,6 +458,7 @@ const TextArea = styled('textarea', {
border: 'none',
padding: '4px',
resize: 'none',
textAlign: 'inherit',
minHeight: 'inherit',
minWidth: 'inherit',
lineHeight: 'inherit',

View file

@ -18,7 +18,7 @@ Object {
"isFilled": false,
"scale": 1,
"size": "small",
"textAlign": "start",
"textAlign": "middle",
},
"text": " ",
"type": "text",

View file

@ -0,0 +1,7 @@
import { Triangle } from '..'
describe('Triangle shape', () => {
it('Creates a shape', () => {
expect(Triangle.create({ id: 'triangle' })).toMatchSnapshot('triangle')
})
})

View file

@ -0,0 +1,388 @@
import * as React from 'react'
import { Utils, SVGContainer, TLBounds } from '@tldraw/core'
import { TriangleShape, TDShapeType, TDMeta, TDShape, DashStyle } from '~types'
import { TDShapeUtil } from '../TDShapeUtil'
import {
defaultStyle,
getShapeStyle,
getBoundsRectangle,
transformRectangle,
transformSingleRectangle,
} from '~state/shapes/shared'
import {
intersectBoundsPolygon,
intersectLineSegmentPolyline,
intersectRayLineSegment,
} from '@tldraw/intersect'
import Vec from '@tldraw/vec'
import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants'
import { getOffsetPolygon } from '../shared/PolygonUtils'
import getStroke, { getStrokePoints } from 'perfect-freehand'
type T = TriangleShape
type E = SVGSVGElement
export class TriangleUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Triangle as const
canBind = true
canClone = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: 'id',
type: TDShapeType.Triangle,
name: 'Triangle',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [1, 1],
rotation: 0,
style: defaultStyle,
},
props
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
const { id, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const { strokeWidth } = styles
const sw = 1 + strokeWidth * 1.618
if (style.dash === DashStyle.Draw) {
const pathTDSnapshot = getTrianglePath(shape)
const indicatorPath = getTriangleIndicatorPathTDSnapshot(shape)
const trianglePoints = getTrianglePoints(shape).join()
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<polygon
className="tl-binding-indicator"
points={trianglePoints}
strokeWidth={this.bindingDistance * 2}
/>
)}
<path
className={style.isFilled || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={indicatorPath}
/>
<path
d={indicatorPath}
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="none"
/>
<path
d={pathTDSnapshot}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="none"
opacity={isGhost ? GHOSTED_OPACITY : 1}
/>
</SVGContainer>
)
}
const points = getTrianglePoints(shape)
const sides = Utils.pointsToLineSegments(points, true)
const paths = sides.map(([start, end], i) => {
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
Vec.dist(start, end),
strokeWidth * 1.618,
shape.style.dash
)
return (
<line
key={id + '_' + i}
x1={start[0]}
y1={start[1]}
x2={end[0]}
y2={end[1]}
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<polygon
className="tl-binding-indicator"
points={points.join()}
strokeWidth={this.bindingDistance * 2}
/>
)}
<polygon
points={points.join()}
fill={styles.fill}
strokeWidth={sw}
stroke="none"
pointerEvents="all"
/>
<g pointerEvents="stroke">{paths}</g>
</SVGContainer>
)
}
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const { style } = shape
const styles = getShapeStyle(style, false)
const sw = styles.strokeWidth
return <polygon points={getTrianglePoints(shape).join()} />
})
private getPoints(shape: T) {
const {
rotation = 0,
point: [x, y],
size: [w, h],
} = shape
return [
[x + w / 2, y],
[x, y + h],
[x + w, y + h],
].map((pt) => Vec.rotWith(pt, this.getCenter(shape), rotation))
}
shouldRender = (prev: T, next: T) => {
return next.size !== prev.size || next.style !== prev.style
}
getBounds = (shape: T) => {
return getBoundsRectangle(shape, this.boundsCache)
}
getExpandedBounds = (shape: T) => {
return Utils.getBoundsFromPoints(
getTrianglePoints(shape, this.bindingDistance).map((pt) => Vec.add(pt, shape.point))
)
}
hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => {
return intersectLineSegmentPolyline(A, B, this.getPoints(shape)).didIntersect
}
hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
return (
Utils.boundsContained(this.getBounds(shape), bounds) ||
intersectBoundsPolygon(bounds, this.getPoints(shape)).length > 0
)
}
getBindingPoint = <K extends TDShape>(
shape: T,
fromShape: K,
point: number[],
origin: number[],
direction: number[],
bindAnywhere: boolean
) => {
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
const expandedBounds = this.getExpandedBounds(shape)
if (!Utils.pointInBounds(point, expandedBounds)) return
const points = getTrianglePoints(shape).map((pt) => Vec.add(pt, shape.point))
const expandedPoints = getTrianglePoints(shape, this.bindingDistance).map((pt) =>
Vec.add(pt, shape.point)
)
const closestDistanceToEdge = Utils.pointsToLineSegments(points, true)
.map(([a, b]) => Vec.distanceToLineSegment(a, b, point))
.sort((a, b) => a - b)[0]
if (
!(Utils.pointInPolygon(point, expandedPoints) || closestDistanceToEdge < this.bindingDistance)
)
return
const intersections = Utils.pointsToLineSegments(expandedPoints.concat([expandedPoints[0]]))
.map((segment) => intersectRayLineSegment(origin, direction, segment[0], segment[1]))
.filter((intersection) => intersection.didIntersect)
.flatMap((intersection) => intersection.points)
if (!intersections.length) return
// The center of the triangle
const center = Vec.add(getTriangleCentroid(shape), shape.point)
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
const intersection = intersections.sort((a, b) => Vec.dist(b, origin) - Vec.dist(a, origin))[0]
// The point between the handle and the intersection
const middlePoint = Vec.med(point, intersection)
let anchor: number[]
let distance: number
if (bindAnywhere) {
anchor = Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point
distance = 0
} else {
if (Vec.distanceToLineSegment(point, middlePoint, center) < BINDING_DISTANCE / 2) {
anchor = center
} else {
anchor = middlePoint
}
if (Utils.pointInPolygon(point, points)) {
distance = this.bindingDistance
} else {
distance = Math.max(this.bindingDistance, closestDistanceToEdge)
}
}
const bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
return {
point: Vec.clampV(bindingPoint, 0, 1),
distance,
}
}
transform = transformRectangle
transformSingle = transformSingleRectangle
}
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
export function getTrianglePoints(shape: T, offset = 0) {
const {
size: [w, h],
} = shape
let points = [
[w / 2, 0],
[w, h],
[0, h],
]
if (offset) points = getOffsetPolygon(points, offset)
return points
}
export function getTriangleCentroid(shape: T) {
const {
size: [w, h],
} = shape
const points = [
[w / 2, 0],
[w, h],
[0, h],
]
return [
(points[0][0] + points[1][0] + points[2][0]) / 3,
(points[0][1] + points[1][1] + points[2][1]) / 3,
]
}
function getTriangleDrawPoints(shape: TriangleShape) {
const styles = getShapeStyle(shape.style)
const {
size: [w, h],
} = shape
const getRandom = Utils.rng(shape.id)
const sw = styles.strokeWidth
// Random corner offsets
const offsets = Array.from(Array(3)).map(() => {
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75]
})
// Corners
const corners = [
Vec.add([w / 2, 0], offsets[0]),
Vec.add([w, h], offsets[1]),
Vec.add([0, h], offsets[2]),
]
// Which side to start drawing first
const rm = Math.round(Math.abs(getRandom() * 2 * 3))
// Number of points per side
// Inset each line by the corner radii and let the freehand algo
// interpolate points for the corners.
const lines = Utils.rotateArray(
[
Vec.pointsBetween(corners[0], corners[1], 32),
Vec.pointsBetween(corners[1], corners[2], 32),
Vec.pointsBetween(corners[2], corners[0], 32),
],
rm
)
// For the final points, include the first half of the first line again,
// so that the line wraps around and avoids ending on a sharp corner.
// This has a bit of finesse and magic—if you change the points between
// function, then you'll likely need to change this one too.
const points = [...lines.flat(), ...lines[0]]
return {
points,
}
}
function getDrawStrokeInfo(shape: TriangleShape) {
const { points } = getTriangleDrawPoints(shape)
const { strokeWidth } = getShapeStyle(shape.style)
const options = {
size: strokeWidth,
thinning: 0.65,
streamline: 0.3,
smoothing: 1,
simulatePressure: false,
last: true,
}
return { points, options }
}
function getTrianglePath(shape: TriangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const stroke = getStroke(points, options)
return Utils.getSvgPathFromStroke(stroke)
}
function getTriangleIndicatorPathTDSnapshot(shape: TriangleShape) {
const { points, options } = getDrawStrokeInfo(shape)
const strokePoints = getStrokePoints(points, options)
return Utils.getSvgPathFromStroke(
strokePoints.map((pt) => pt.point.slice(0, 2)),
false
)
}

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Triangle shape Creates a shape: triangle 1`] = `
Object {
"childIndex": 1,
"id": "triangle",
"name": "Triangle",
"parentId": "page",
"point": Array [
0,
0,
],
"rotation": 0,
"size": Array [
1,
1,
],
"style": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"type": "triangle",
}
`;

View file

@ -0,0 +1 @@
export * from './TriangleUtil'

View file

@ -1,5 +1,6 @@
import type { TDShapeUtil } from './TDShapeUtil'
import { RectangleUtil } from './RectangleUtil'
import { TriangleUtil } from './TriangleUtil'
import { EllipseUtil } from './EllipseUtil'
import { ArrowUtil } from './ArrowUtil'
import { GroupUtil } from './GroupUtil'
@ -9,6 +10,7 @@ import { DrawUtil } from './DrawUtil'
import { TDShape, TDShapeType } from '~types'
export const Rectangle = new RectangleUtil()
export const Triangle = new TriangleUtil()
export const Ellipse = new EllipseUtil()
export const Draw = new DrawUtil()
export const Arrow = new ArrowUtil()
@ -18,6 +20,7 @@ export const Sticky = new StickyUtil()
export const shapeUtils = {
[TDShapeType.Rectangle]: Rectangle,
[TDShapeType.Triangle]: Triangle,
[TDShapeType.Ellipse]: Ellipse,
[TDShapeType.Draw]: Draw,
[TDShapeType.Arrow]: Arrow,

View file

@ -0,0 +1,152 @@
import { intersectLineLine } from '@tldraw/intersect'
import Vec from '@tldraw/vec'
const PI2 = Math.PI * 2
type Vert = number[]
type Edge = Vert[]
type Polygon = Vert[]
export class PolygonUtils {
static inwardEdgeNormal(edge: Edge) {
// Assuming that polygon vertices are in clockwise order
const delta = Vec.sub(edge[1], edge[0])
const len = Vec.len2(delta)
return [-delta[0] / len, delta[1] / len]
}
static outwardEdgeNormal(edge: Edge) {
return Vec.neg(PolygonUtils.inwardEdgeNormal(edge))
}
// If the slope of line v1,v2 greater than the slope of v1,p then p is on the left side of v1,v2 and the return value is > 0.
// If p is colinear with v1,v2 then return 0, otherwise return a value < 0.
static leftSide = Vec.isLeft
static isReflexVertex(polygon: Polygon, index: number) {
const len = polygon.length
// Assuming that polygon vertices are in clockwise order
const v0 = polygon[(index + len - 1) % len]
const v1 = polygon[index]
const v2 = polygon[(index + 1) % len]
if (PolygonUtils.leftSide(v0, v2, v1) < 0) return true
return false
}
static getEdges(vertices: Vert[]) {
return vertices.map((vert, i) => [vert, vertices[(i + 1) % vertices.length]])
}
// based on http://local.wasp.uwa.edu.au/~pbourke/geometry/lineline2d/, A => "line a", B => "line b"
static edgesIntersection([A1, A2]: number[][], [B1, B2]: number[][]) {
const den = (B2[1] - B1[1]) * (A2[0] - A1[0]) - (B2[0] - B1[0]) * (A2[1] - A1[1])
if (den == 0) return null // lines are parallel or conincident
const ua = ((B2[0] - B1[0]) * (A1[1] - B1[1]) - (B2[1] - B1[1]) * (A1[0] - B1[0])) / den
const ub = ((A2[0] - A1[0]) * (A1[1] - B1[1]) - (A2[1] - A1[1]) * (A1[0] - B1[0])) / den
if (ua < 0 || ub < 0 || ua > 1 || ub > 1) return null
return [A1[0] + ua * (A2[0] - A1[0]), A1[1] + ua * (A2[1] - A1[1])]
}
static appendArc(
polygon: number[][],
center: number[],
radius: number,
startVertex: number[],
endVertex: number[],
isPaddingBoundary = false
) {
const vertices = [...polygon]
let startAngle = Math.atan2(startVertex[1] - center[1], startVertex[0] - center[0])
let endAngle = Math.atan2(endVertex[1] - center[1], endVertex[0] - center[0])
if (startAngle < 0) startAngle += PI2
if (endAngle < 0) endAngle += PI2
const arcSegmentCount = 5 // An odd number so that one arc vertex will be eactly arcRadius from center.
const angle = startAngle > endAngle ? startAngle - endAngle : startAngle + PI2 - endAngle
const angle5 = (isPaddingBoundary ? -angle : PI2 - angle) / arcSegmentCount
vertices.push(startVertex)
for (let i = 1; i < arcSegmentCount; ++i) {
const angle = startAngle + angle5 * i
vertices.push([center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius])
}
vertices.push(endVertex)
return vertices
}
static createOffsetEdge(edge: Edge, offset: number[]) {
return edge.map((vert) => Vec.add(vert, offset))
}
static getOffsetPolygon(polygon: Polygon, offset = 0) {
const edges = PolygonUtils.getEdges(polygon)
const offsetEdges = edges.map((edge) =>
PolygonUtils.createOffsetEdge(edge, Vec.mul(PolygonUtils.outwardEdgeNormal(edge), offset))
)
const vertices = []
for (let i = 0; i < offsetEdges.length; i++) {
const thisEdge = offsetEdges[i]
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
const vertex = PolygonUtils.edgesIntersection(prevEdge, thisEdge)
if (vertex) vertices.push(vertex)
else {
PolygonUtils.appendArc(vertices, edges[i][0], offset, prevEdge[1], thisEdge[0], false)
}
}
// var marginPolygon = PolygonUtils.createPolygon(vertices)
// marginPolygon.offsetEdges = offsetEdges
return vertices
}
static createPaddingPolygon(polygon: number[][][], shapePadding = 0) {
const offsetEdges = polygon.map((edge) =>
PolygonUtils.createOffsetEdge(edge, PolygonUtils.inwardEdgeNormal(edge))
)
const vertices = []
for (let i = 0; i < offsetEdges.length; i++) {
const thisEdge = offsetEdges[i]
const prevEdge = offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length]
const vertex = PolygonUtils.edgesIntersection(prevEdge, thisEdge)
if (vertex) vertices.push(vertex)
else {
PolygonUtils.appendArc(
vertices,
polygon[i][0],
shapePadding,
prevEdge[1],
thisEdge[0],
true
)
}
}
return vertices
}
}
export function getOffsetPolygon(points: number[][], offset: number) {
if (points.length < 3) throw Error('Polygon must have at least 3 points')
const len = points.length
return points
.map((point, i) => [point, points[(i + 1) % len]])
.map(([A, B]) => {
const offsetVector = Vec.mul(Vec.per(Vec.uni(Vec.sub(B, A))), offset)
return [Vec.add(A, offsetVector), Vec.add(B, offsetVector)]
})
.map((edge, i, edges) => {
const intersection = intersectLineLine(edge, edges[(i + 1) % edges.length])
if (intersection === undefined) throw Error('Expected an intersection')
return intersection
})
}

View file

@ -126,7 +126,7 @@ export function getFontStyle(style: ShapeStyles): string {
const fontFace = getFontFace(style.font)
const { scale = 1 } = style
return `${fontSize * scale}px/1.3 ${fontFace}`
return `${fontSize * scale}px/1 ${fontFace}`
}
export function getStickyFontStyle(style: ShapeStyles): string {
@ -134,7 +134,7 @@ export function getStickyFontStyle(style: ShapeStyles): string {
const fontFace = getFontFace(style.font)
const { scale = 1 } = style
return `${fontSize * scale}px/1.3 ${fontFace}`
return `${fontSize * scale}px/1 ${fontFace}`
}
export function getStickyShapeStyle(style: ShapeStyles, isDarkMode = false) {
@ -183,5 +183,5 @@ export const defaultStyle: ShapeStyles = {
export const defaultTextStyle: ShapeStyles = {
...defaultStyle,
font: FontStyle.Script,
textAlign: AlignStyle.Start,
textAlign: AlignStyle.Middle,
}

View file

@ -0,0 +1,9 @@
import { TldrawApp } from '~state'
import { TriangleTool } from '.'
describe('TriangleTool', () => {
it('creates tool', () => {
const app = new TldrawApp()
new TriangleTool(app)
})
})

View file

@ -0,0 +1,45 @@
import { Utils, TLPointerEventHandler, TLBoundsCorner } from '@tldraw/core'
import Vec from '@tldraw/vec'
import { Triangle } from '~state/shapes'
import { SessionType, TDShapeType } from '~types'
import { BaseTool, Status } from '../BaseTool'
export class TriangleTool extends BaseTool {
type = TDShapeType.Triangle as const
/* ----------------- Event Handlers ----------------- */
onPointerDown: TLPointerEventHandler = () => {
if (this.status !== Status.Idle) return
const {
currentPoint,
currentGrid,
settings: { showGrid },
appState: { currentPageId, currentStyle },
} = this.app
const childIndex = this.getNextChildIndex()
const id = Utils.uniqueId()
const newShape = Triangle.create({
id,
parentId: currentPageId,
childIndex,
point: showGrid ? Vec.snap(currentPoint, currentGrid) : currentPoint,
style: { ...currentStyle },
})
this.app.patchCreate([newShape])
this.app.startSession(
SessionType.TransformSingle,
newShape.id,
TLBoundsCorner.BottomRight,
true
)
this.setStatus(Status.Creating)
}
}

View file

@ -0,0 +1 @@
export * from './TriangleTool'

View file

@ -4,6 +4,7 @@ import { LineTool } from './LineTool'
import { DrawTool } from './DrawTool'
import { EllipseTool } from './EllipseTool'
import { RectangleTool } from './RectangleTool'
import { TriangleTool } from './TriangleTool'
import { SelectTool } from './SelectTool'
import { StickyTool } from './StickyTool'
import { TextTool } from './TextTool'
@ -16,6 +17,7 @@ export interface ToolsMap {
[TDShapeType.Draw]: typeof DrawTool
[TDShapeType.Ellipse]: typeof EllipseTool
[TDShapeType.Rectangle]: typeof RectangleTool
[TDShapeType.Triangle]: typeof TriangleTool
[TDShapeType.Line]: typeof LineTool
[TDShapeType.Arrow]: typeof ArrowTool
[TDShapeType.Sticky]: typeof StickyTool
@ -32,6 +34,7 @@ export const tools: { [K in TDToolType]: ToolsMap[K] } = {
[TDShapeType.Draw]: DrawTool,
[TDShapeType.Ellipse]: EllipseTool,
[TDShapeType.Rectangle]: RectangleTool,
[TDShapeType.Triangle]: TriangleTool,
[TDShapeType.Line]: LineTool,
[TDShapeType.Arrow]: ArrowTool,
[TDShapeType.Sticky]: StickyTool,

View file

@ -206,6 +206,7 @@ export type TDToolType =
| TDShapeType.Draw
| TDShapeType.Ellipse
| TDShapeType.Rectangle
| TDShapeType.Triangle
| TDShapeType.Line
| TDShapeType.Arrow
| TDShapeType.Sticky
@ -270,6 +271,7 @@ export enum TDShapeType {
Sticky = 'sticky',
Ellipse = 'ellipse',
Rectangle = 'rectangle',
Triangle = 'triangle',
Draw = 'draw',
Arrow = 'arrow',
Line = 'line',
@ -336,6 +338,12 @@ export interface RectangleShape extends TDBaseShape {
size: number[]
}
// The shape created by the Triangle tool
export interface TriangleShape extends TDBaseShape {
type: TDShapeType.Triangle
size: number[]
}
// The shape created by the text tool
export interface TextShape extends TDBaseShape {
type: TDShapeType.Text
@ -360,6 +368,7 @@ export interface GroupShape extends TDBaseShape {
export type TDShape =
| RectangleShape
| EllipseShape
| TriangleShape
| DrawShape
| ArrowShape
| TextShape

View file

@ -458,6 +458,7 @@ export class Vec {
* @returns
*/
static nudge = (A: number[], B: number[], d: number): number[] => {
if (Vec.isEqual(A, B)) return A
return Vec.add(A, Vec.mul(Vec.uni(Vec.sub(B, A)), d))
}
@ -494,6 +495,16 @@ export class Vec {
return [...Vec.lrp(A, B, t), k]
})
}
/**
* Get the slope between two points.
* @param A
* @param B
*/
static slope = (A: number[], B: number[]) => {
if (A[0] === B[0]) return NaN
return (A[1] - B[1]) / (A[0] - B[0])
}
}
export default Vec