diff --git a/packages/core/package.json b/packages/core/package.json index cd92eed46..de64364bb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,4 +91,4 @@ } }, "gitHead": "3ab5db27b9e83736fdae934474e80e90c854922c" -} +} \ No newline at end of file diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index 6267f369c..d69748184 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -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 { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0fa656f1b..ed76e8ff1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -106,6 +106,7 @@ export interface TLTheme { brushStroke?: string selectFill?: string selectStroke?: string + binding: string background?: string foreground?: string grid?: string diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index 7c08718b5..108482434 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -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) diff --git a/packages/intersect/src/index.ts b/packages/intersect/src/index.ts index 18d8ea705..60bf48888 100644 --- a/packages/intersect/src/index.ts +++ b/packages/intersect/src/index.ts @@ -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( + (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 +} diff --git a/packages/tldraw/src/components/ToolsPanel/PrimaryTools.tsx b/packages/tldraw/src/components/ToolsPanel/PrimaryTools.tsx index e8510fa1c..d87953fb9 100644 --- a/packages/tldraw/src/components/ToolsPanel/PrimaryTools.tsx +++ b/packages/tldraw/src/components/ToolsPanel/PrimaryTools.tsx @@ -75,7 +75,7 @@ export const PrimaryTools = React.memo(function PrimaryTools(): JSX.Element { , [TDShapeType.Ellipse]: , + [TDShapeType.Triangle]: , [TDShapeType.Line]: , } diff --git a/packages/tldraw/src/constants.ts b/packages/tldraw/src/constants.ts index 796c2b353..71fb1bc4b 100644 --- a/packages/tldraw/src/constants.ts +++ b/packages/tldraw/src/constants.ts @@ -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 diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index ca77bf5d3..918677ef4 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -67,7 +67,17 @@ export function useKeyboardShortcuts(ref: React.RefObject) { ) 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) { ) useHotkeys( - 'a,7', + 'a,8', () => { if (!canHandleEvent(true)) return app.selectTool(TDShapeType.Arrow) @@ -87,7 +97,7 @@ export function useKeyboardShortcuts(ref: React.RefObject) { ) useHotkeys( - 't,8', + 't,9', () => { if (!canHandleEvent(true)) return app.selectTool(TDShapeType.Text) @@ -97,7 +107,7 @@ export function useKeyboardShortcuts(ref: React.RefObject) { ) useHotkeys( - 's,9', + 's,0', () => { if (!canHandleEvent(true)) return app.selectTool(TDShapeType.Sticky) diff --git a/packages/tldraw/src/state/TLDR.ts b/packages/tldraw/src/state/TLDR.ts index 18c37d930..73c876f8b 100644 --- a/packages/tldraw/src/state/TLDR.ts +++ b/packages/tldraw/src/state/TLDR.ts @@ -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) } } - } diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 1c8af5913..9006563fb 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -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 { [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 { binding, toShape, toUtils.getBounds(toShape), + toUtils.getExpandedBounds(toShape), toUtils.getCenter(toShape) ) @@ -735,6 +738,7 @@ export class TldrawApp extends StateManager { binding, toShape, toUtils.getBounds(toShape), + toUtils.getExpandedBounds(toShape), toUtils.getCenter(toShape) ) diff --git a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts index d09d8358b..2bfd1bbfb 100644 --- a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts +++ b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts @@ -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) diff --git a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts index 7ed444e17..f744bdabd 100644 --- a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts +++ b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts @@ -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(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 diff --git a/packages/tldraw/src/state/sessions/ArrowSession/__snapshots__/ArrowSession.spec.ts.snap b/packages/tldraw/src/state/sessions/ArrowSession/__snapshots__/ArrowSession.spec.ts.snap new file mode 100644 index 000000000..68a52fa3f --- /dev/null +++ b/packages/tldraw/src/state/sessions/ArrowSession/__snapshots__/ArrowSession.spec.ts.snap @@ -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, +] +`; diff --git a/packages/tldraw/src/state/sessions/BrushSession/BrushSession.spec.ts b/packages/tldraw/src/state/sessions/BrushSession/BrushSession.spec.ts index 434be1b8a..b5b362f72 100644 --- a/packages/tldraw/src/state/sessions/BrushSession/BrushSession.spec.ts +++ b/packages/tldraw/src/state/sessions/BrushSession/BrushSession.spec.ts @@ -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', () => { diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx b/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx index 684a19d8b..4e3d6ec55 100644 --- a/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx +++ b/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx @@ -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 { binding: TDBinding, target: TDShape, targetBounds: TLBounds, + expandedBounds: TLBounds, center: number[] ): Partial | 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 { 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 { ) // 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 { 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) } diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx b/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx index 5a26f1446..f2c131433 100644 --- a/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx +++ b/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx @@ -64,8 +64,9 @@ export class EllipseUtil extends TDShapeUtil { className="tl-binding-indicator" cx={radiusX} cy={radiusY} - rx={rx + 2} - ry={ry + 2} + rx={rx} + ry={ry} + strokeWidth={this.bindingDistance} /> )} { ) } - 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 { className="tl-binding-indicator" cx={radiusX} cy={radiusY} - rx={rx + 32} - ry={ry + 32} + rx={rx} + ry={ry} + strokeWidth={this.bindingDistance} /> )} { Indicator = TDShapeUtil.Indicator(({ 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 ? ( ) : ( - + ) }) @@ -231,13 +237,10 @@ export class EllipseUtil extends TDShapeUtil { 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 { !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 { 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 { return undefined } - distance = Math.max(BINDING_DISTANCE / 2, Vec.dist(point, innerIntersection)) + distance = Math.max(this.bindingDistance / 2, Vec.dist(point, innerIntersection)) } } diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts b/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts index 348139d84..1b077b914 100644 --- a/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts +++ b/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts @@ -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 }, diff --git a/packages/tldraw/src/state/shapes/GroupUtil/GroupUtil.tsx b/packages/tldraw/src/state/shapes/GroupUtil/GroupUtil.tsx index e5097d696..78fd6c5a5 100644 --- a/packages/tldraw/src/state/shapes/GroupUtil/GroupUtil.tsx +++ b/packages/tldraw/src/state/shapes/GroupUtil/GroupUtil.tsx @@ -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 { return ( {isBinding && ( - + )} { {isBinding && ( )} { /> )} {style.isFilled && ( { } Component = TDShapeUtil.Component( - ({ 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 { isGhost={isGhost} style={{ backgroundColor: fill, ...style }} > + {isBinding && ( +
+ )} {shape.text}​ diff --git a/packages/tldraw/src/state/shapes/TDShapeUtil.tsx b/packages/tldraw/src/state/shapes/TDShapeUtil.tsx index a2f2af611..4ad6cb6b7 100644 --- a/packages/tldraw/src/state/shapes/TDShapeUtil.tsx +++ b/packages/tldraw/src/state/shapes/TDShapeUtil.tsx @@ -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 extends TLShapeUtil< T, @@ -28,6 +29,8 @@ export abstract class TDShapeUtil ex hideResizeHandles = false + bindingDistance = BINDING_DISTANCE + abstract getShape: (props: Partial) => T hitTestPoint = (shape: T, point: number[]): boolean => { @@ -53,74 +56,68 @@ export abstract class TDShapeUtil ex return Utils.getBoundsCenter(this.getBounds(shape)) } + getExpandedBounds = (shape: T) => { + return Utils.expandBounds(this.getBounds(shape), this.bindingDistance) + } + getBindingPoint = ( 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 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 ex binding: TDBinding, target: TDShape, targetBounds: TLBounds, + expandedBounds: TLBounds, center: number[] ) => Partial | void diff --git a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx index 71ee13c4e..8ef68b3a2 100644 --- a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx +++ b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx @@ -27,6 +27,8 @@ export class TextUtil extends TDShapeUtil { canClone = true + bindingDistance = BINDING_DISTANCE / 2 + getShape = (props: Partial): T => { return Utils.deepMerge( { @@ -191,10 +193,10 @@ export class TextUtil extends TDShapeUtil { 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 { 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', diff --git a/packages/tldraw/src/state/shapes/TextUtil/__snapshots__/TextUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/TextUtil/__snapshots__/TextUtil.spec.tsx.snap index 15f3e1e66..853cd8650 100644 --- a/packages/tldraw/src/state/shapes/TextUtil/__snapshots__/TextUtil.spec.tsx.snap +++ b/packages/tldraw/src/state/shapes/TextUtil/__snapshots__/TextUtil.spec.tsx.snap @@ -18,7 +18,7 @@ Object { "isFilled": false, "scale": 1, "size": "small", - "textAlign": "start", + "textAlign": "middle", }, "text": " ", "type": "text", diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.spec.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.spec.tsx new file mode 100644 index 000000000..93efbc0f4 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.spec.tsx @@ -0,0 +1,7 @@ +import { Triangle } from '..' + +describe('Triangle shape', () => { + it('Creates a shape', () => { + expect(Triangle.create({ id: 'triangle' })).toMatchSnapshot('triangle') + }) +}) diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx new file mode 100644 index 000000000..b48c13ae3 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx @@ -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 { + type = TDShapeType.Triangle as const + + canBind = true + + canClone = true + + getShape = (props: Partial): T => { + return Utils.deepMerge( + { + 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( + ({ 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 ( + + {isBinding && ( + + )} + + + + + ) + } + + 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 ( + + ) + }) + return ( + + {isBinding && ( + + )} + + {paths} + + ) + } + ) + + Indicator = TDShapeUtil.Indicator(({ shape }) => { + const { style } = shape + const styles = getShapeStyle(style, false) + const sw = styles.strokeWidth + return + }) + + 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 = ( + 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 + ) +} diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap new file mode 100644 index 000000000..578330bb9 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap @@ -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", +} +`; diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/index.ts b/packages/tldraw/src/state/shapes/TriangleUtil/index.ts new file mode 100644 index 000000000..a69e2b8e7 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/index.ts @@ -0,0 +1 @@ +export * from './TriangleUtil' diff --git a/packages/tldraw/src/state/shapes/index.ts b/packages/tldraw/src/state/shapes/index.ts index 7d360d43c..d01e67adc 100644 --- a/packages/tldraw/src/state/shapes/index.ts +++ b/packages/tldraw/src/state/shapes/index.ts @@ -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, diff --git a/packages/tldraw/src/state/shapes/shared/PolygonUtils.ts b/packages/tldraw/src/state/shapes/shared/PolygonUtils.ts new file mode 100644 index 000000000..628f3979e --- /dev/null +++ b/packages/tldraw/src/state/shapes/shared/PolygonUtils.ts @@ -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 + }) +} diff --git a/packages/tldraw/src/state/shapes/shared/shape-styles.ts b/packages/tldraw/src/state/shapes/shared/shape-styles.ts index 0ed2e357f..246d572bf 100644 --- a/packages/tldraw/src/state/shapes/shared/shape-styles.ts +++ b/packages/tldraw/src/state/shapes/shared/shape-styles.ts @@ -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, } diff --git a/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.spec.ts b/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.spec.ts new file mode 100644 index 000000000..46f320f3c --- /dev/null +++ b/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.spec.ts @@ -0,0 +1,9 @@ +import { TldrawApp } from '~state' +import { TriangleTool } from '.' + +describe('TriangleTool', () => { + it('creates tool', () => { + const app = new TldrawApp() + new TriangleTool(app) + }) +}) diff --git a/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts b/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts new file mode 100644 index 000000000..4a33329c6 --- /dev/null +++ b/packages/tldraw/src/state/tools/TriangleTool/TriangleTool.ts @@ -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) + } +} diff --git a/packages/tldraw/src/state/tools/TriangleTool/index.ts b/packages/tldraw/src/state/tools/TriangleTool/index.ts new file mode 100644 index 000000000..cede60195 --- /dev/null +++ b/packages/tldraw/src/state/tools/TriangleTool/index.ts @@ -0,0 +1 @@ +export * from './TriangleTool' diff --git a/packages/tldraw/src/state/tools/index.ts b/packages/tldraw/src/state/tools/index.ts index 5c4f13bba..bc4090b62 100644 --- a/packages/tldraw/src/state/tools/index.ts +++ b/packages/tldraw/src/state/tools/index.ts @@ -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, diff --git a/packages/tldraw/src/types.ts b/packages/tldraw/src/types.ts index efd4812b0..22c19064d 100644 --- a/packages/tldraw/src/types.ts +++ b/packages/tldraw/src/types.ts @@ -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 diff --git a/packages/vec/src/index.ts b/packages/vec/src/index.ts index 47960cc62..791d8f060 100644 --- a/packages/vec/src/index.ts +++ b/packages/vec/src/index.ts @@ -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