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:
parent
86c651764c
commit
c5124b160e
37 changed files with 918 additions and 139 deletions
|
@ -91,4 +91,4 @@
|
|||
}
|
||||
},
|
||||
"gitHead": "3ab5db27b9e83736fdae934474e80e90c854922c"
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -106,6 +106,7 @@ export interface TLTheme {
|
|||
brushStroke?: string
|
||||
selectFill?: string
|
||||
selectStroke?: string
|
||||
binding: string
|
||||
background?: string
|
||||
foreground?: string
|
||||
grid?: string
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
`;
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}​
|
||||
</StyledText>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -18,7 +18,7 @@ Object {
|
|||
"isFilled": false,
|
||||
"scale": 1,
|
||||
"size": "small",
|
||||
"textAlign": "start",
|
||||
"textAlign": "middle",
|
||||
},
|
||||
"text": " ",
|
||||
"type": "text",
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Triangle } from '..'
|
||||
|
||||
describe('Triangle shape', () => {
|
||||
it('Creates a shape', () => {
|
||||
expect(Triangle.create({ id: 'triangle' })).toMatchSnapshot('triangle')
|
||||
})
|
||||
})
|
388
packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx
Normal file
388
packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx
Normal 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
|
||||
)
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
`;
|
1
packages/tldraw/src/state/shapes/TriangleUtil/index.ts
Normal file
1
packages/tldraw/src/state/shapes/TriangleUtil/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './TriangleUtil'
|
|
@ -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,
|
||||
|
|
152
packages/tldraw/src/state/shapes/shared/PolygonUtils.ts
Normal file
152
packages/tldraw/src/state/shapes/shared/PolygonUtils.ts
Normal 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
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { TldrawApp } from '~state'
|
||||
import { TriangleTool } from '.'
|
||||
|
||||
describe('TriangleTool', () => {
|
||||
it('creates tool', () => {
|
||||
const app = new TldrawApp()
|
||||
new TriangleTool(app)
|
||||
})
|
||||
})
|
45
packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts
Normal file
45
packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts
Normal 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)
|
||||
}
|
||||
}
|
1
packages/tldraw/src/state/tools/TriangleTool/index.ts
Normal file
1
packages/tldraw/src/state/tools/TriangleTool/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './TriangleTool'
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue