Push a few more methods to the base shape utils class

This commit is contained in:
Steve Ruiz 2021-09-11 18:07:53 +01:00
parent f4e8631482
commit dea7d5c7d4
14 changed files with 347 additions and 35 deletions

View file

@ -0,0 +1,15 @@
import * as React from 'react'
interface HTMLContainerProps extends React.HTMLProps<HTMLDivElement> {
children: React.ReactNode
}
export const HTMLContainer = React.memo(
React.forwardRef<HTMLDivElement, HTMLContainerProps>(({ children, ...rest }, ref) => {
return (
<div ref={ref} className="tl-positioned-div" {...rest}>
{children}
</div>
)
})
)

View file

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

View file

@ -1,3 +1,4 @@
export * from './renderer'
export { brushUpdater } from './brush'
export * from './svg-container'
export * from './html-container'

View file

@ -153,6 +153,13 @@ const tlcss = css`
height: 100%;
}
.tl-positioned-div {
position: relative;
width: 100%;
height: 100%;
padding: var(--tl-padding);
}
.tl-layer {
transform: scale(var(--tl-zoom), var(--tl-zoom))
translate(var(--tl-camera-x), var(--tl-camera-y));

View file

@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* --------------------- Primary -------------------- */
import { Intersect, Vec } from '+utils'
import React, { ForwardedRef } from 'react'
export type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
@ -304,16 +305,6 @@ export abstract class TLShapeUtil<T extends TLShape, E extends HTMLElement | SVG
abstract getRotatedBounds(shape: T): TLBounds
abstract hitTest(shape: T, point: number[]): boolean
abstract hitTestBounds(shape: T, bounds: TLBounds): boolean
abstract transform(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T>
transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> {
return this.transform(shape, bounds, info)
}
shouldRender(_prev: T, _next: T): boolean {
return true
}
@ -356,6 +347,14 @@ export abstract class TLShapeUtil<T extends TLShape, E extends HTMLElement | SVG
return { ...shape, ...props }
}
transform(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
return undefined
}
transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo<T>): Partial<T> | void {
return this.transform(shape, bounds, info)
}
updateChildren<K extends TLShape>(shape: T, children: K[]): Partial<K>[] | void {
return
}
@ -409,6 +408,40 @@ export abstract class TLShapeUtil<T extends TLShape, E extends HTMLElement | SVG
onStyleChange(shape: T): Partial<T> | void {
return
}
hitTest(shape: T, point: number[]) {
const bounds = this.getBounds(shape)
return !(
point[0] < bounds.minX ||
point[0] > bounds.maxX ||
point[1] < bounds.minY ||
point[1] > bounds.maxY
)
}
hitTestBounds(shape: T, bounds: TLBounds) {
const { minX, minY, maxX, maxY, width, height } = this.getBounds(shape)
const center = [minX + width / 2, minY + height / 2]
const corners = [
[minX, minY],
[maxX, minY],
[maxX, maxY],
[minX, maxY],
].map((point) => Vec.rotWith(point, center, shape.rotation || 0))
return (
corners.every(
(point) =>
!(
point[0] < bounds.minX ||
point[0] > bounds.maxX ||
point[1] < bounds.minY ||
point[1] > bounds.maxY
)
) || Intersect.polyline.bounds(corners, bounds).length > 0
)
}
}
/* -------------------- Internal -------------------- */

View file

@ -2,7 +2,7 @@ import * as React from 'react'
import { IdProvider } from '@radix-ui/react-id'
import { Renderer } from '@tldraw/core'
import styled from '~styles'
import type { Data, TLDrawDocument } from '~types'
import { Data, TLDrawDocument, TLDrawStatus, TLDrawToolType } from '~types'
import { TLDrawState } from '~state'
import { TLDrawContext, useCustomFonts, useKeyboardShortcuts, useTLDrawContext } from '~hooks'
import { tldrawShapeUtils } from '~shape'
@ -109,7 +109,8 @@ function InnerTldraw({
const hideHandles = isInSession || !isSelecting
// Hide indicators when not using the select tool, or when in session
const hideIndicators = isInSession || !isSelecting
const hideIndicators =
(isInSession && tlstate.appState.status.current !== TLDrawStatus.Brushing) || !isSelecting
// Custom rendering meta, with dark mode for shapes
const meta = React.useMemo(() => ({ isDarkMode }), [isDarkMode])

View file

@ -1,4 +1,4 @@
import { Rectangle, Ellipse, Arrow, Draw, Text, Group } from './shapes'
import { Rectangle, Ellipse, Arrow, Draw, Text, Group, PostIt } from './shapes'
import { TLDrawShapeType, TLDrawShape, TLDrawShapeUtil, TLDrawShapeUtils } from '~types'
export const tldrawShapeUtils: TLDrawShapeUtils = {
@ -8,6 +8,7 @@ export const tldrawShapeUtils: TLDrawShapeUtils = {
[TLDrawShapeType.Arrow]: new Arrow(),
[TLDrawShapeType.Text]: new Text(),
[TLDrawShapeType.Group]: new Group(),
[TLDrawShapeType.PostIt]: new PostIt(),
} as TLDrawShapeUtils
export type ShapeByType<T extends keyof TLDrawShapeUtils> = TLDrawShapeUtils[T]

View file

@ -4,3 +4,4 @@ export * from './rectangle'
export * from './ellipse'
export * from './text'
export * from './group'
export * from './post-it'

View file

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

View file

@ -0,0 +1,7 @@
import { PostIt } from './post-it'
describe('Post-It shape', () => {
it('Creates an instance', () => {
new PostIt()
})
})

View file

@ -0,0 +1,250 @@
import * as React from 'react'
import {
TLBounds,
Utils,
Vec,
TLTransformInfo,
Intersect,
TLShapeProps,
HTMLContainer,
} from '@tldraw/core'
import { defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { PostItShape, TLDrawShapeUtil, TLDrawShapeType, TLDrawToolType, ArrowShape } from '~types'
// TODO
// [ ] - Make sure that fill does not extend drawn shape at corners
export class PostIt extends TLDrawShapeUtil<PostItShape, HTMLDivElement> {
type = TLDrawShapeType.PostIt as const
toolType = TLDrawToolType.Bounds
canBind = true
pathCache = new WeakMap<number[], string>([])
defaultProps: PostItShape = {
id: 'id',
type: TLDrawShapeType.PostIt as const,
name: 'PostIt',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [1, 1],
text: '',
rotation: 0,
style: defaultStyle,
}
shouldRender(prev: PostItShape, next: PostItShape) {
return next.size !== prev.size || next.style !== prev.style
}
render = React.forwardRef<HTMLDivElement, TLShapeProps<PostItShape, HTMLDivElement>>(
({ shape, isBinding, meta, events }, ref) => {
const [count, setCount] = React.useState(0)
return (
<HTMLContainer ref={ref} {...events}>
<div
style={{
pointerEvents: 'all',
backgroundColor: 'rgba(255, 220, 100)',
border: '1px solid black',
fontFamily: 'sans-serif',
height: '100%',
width: '100%',
}}
>
<div onPointerDown={(e) => e.preventDefault()}>
<input
type="textarea"
style={{ width: '100%', height: '50%', background: 'none' }}
onPointerDown={(e) => e.stopPropagation()}
/>
<button onPointerDown={() => setCount((count) => count + 1)}>{count}</button>
</div>
</div>
</HTMLContainer>
)
}
)
renderIndicator(shape: PostItShape) {
const {
style,
size: [width, height],
} = shape
const styles = getShapeStyle(style, false)
const strokeWidth = +styles.strokeWidth
const sw = strokeWidth
return (
<rect
x={sw / 2}
y={sw / 2}
rx={1}
ry={1}
width={Math.max(1, width - sw)}
height={Math.max(1, height - sw)}
/>
)
}
getBounds(shape: PostItShape) {
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
const [width, height] = shape.size
return {
minX: 0,
maxX: width,
minY: 0,
maxY: height,
width,
height,
}
})
return Utils.translateBounds(bounds, shape.point)
}
getRotatedBounds(shape: PostItShape) {
return Utils.getBoundsFromPoints(Utils.getRotatedCorners(this.getBounds(shape), shape.rotation))
}
getCenter(shape: PostItShape): number[] {
return Utils.getBoundsCenter(this.getBounds(shape))
}
getBindingPoint(
shape: PostItShape,
fromShape: ArrowShape,
point: number[],
origin: number[],
direction: number[],
padding: number,
anywhere: boolean
) {
const bounds = this.getBounds(shape)
const expandedBounds = Utils.expandBounds(bounds, padding)
let bindingPoint: number[]
let distance: number
// 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 (anywhere) {
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,
])
}
distance = 0
} else {
// TODO: What if the shape has a curve? In that case, should we
// intersect the circle-from-three-points instead?
// Find furthest intersection between ray from
// origin through point and expanded bounds.
// TODO: Make this a ray vs rounded rect intersection
const intersection = Intersect.ray
.bounds(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
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
} else {
// Or else calculate a normalized point
bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
if (Utils.pointInBounds(point, bounds)) {
distance = 16
} else {
// If the binding point was close to the shape's center, snap to the center
// Find the distance between the point and the real bounds of the shape
distance = Math.max(
16,
Utils.getBoundsSides(bounds)
.map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
.sort((a, b) => a - b)[0]
)
}
}
return {
point: Vec.clampV(bindingPoint, 0, 1),
distance,
}
}
hitTestBounds(shape: PostItShape, bounds: TLBounds) {
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
return (
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
Intersect.polyline.bounds(rotatedCorners, bounds).length > 0
)
}
transform(
shape: PostItShape,
bounds: TLBounds,
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<PostItShape>
) {
if (!shape.rotation && !shape.isAspectRatioLocked) {
return {
point: Vec.round([bounds.minX, bounds.minY]),
size: Vec.round([bounds.width, bounds.height]),
}
} else {
const size = Vec.round(
Vec.mul(initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY)))
)
const point = Vec.round([
bounds.minX +
(bounds.width - shape.size[0]) *
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +
(bounds.height - shape.size[1]) *
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
])
const rotation =
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
? initialShape.rotation
? -initialShape.rotation
: 0
: initialShape.rotation
return {
size,
point,
rotation,
}
}
}
transformSingle(_shape: PostItShape, bounds: TLBounds) {
return {
size: Vec.round([bounds.width, bounds.height]),
point: Vec.round([bounds.minX, bounds.minY]),
}
}
}

View file

@ -7,6 +7,7 @@ import {
Intersect,
TLShapeProps,
SVGContainer,
HTMLContainer,
} from '@tldraw/core'
import getStroke from 'perfect-freehand'
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
@ -120,7 +121,7 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
})
return (
<g ref={ref} {...events}>
<SVGContainer ref={ref} {...events}>
{isBinding && (
<rect
className="tl-binding-indicator"
@ -141,7 +142,7 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
pointerEvents="all"
/>
<g pointerEvents="stroke">{paths}</g>
</g>
</SVGContainer>
)
}
)
@ -272,19 +273,6 @@ export class Rectangle extends TLDrawShapeUtil<RectangleShape, SVGSVGElement> {
}
}
hitTest(shape: RectangleShape, point: number[]) {
return Utils.pointInBounds(point, this.getBounds(shape))
}
hitTestBounds(shape: RectangleShape, bounds: TLBounds) {
const rotatedCorners = Utils.getRotatedCorners(this.getBounds(shape), shape.rotation)
return (
rotatedCorners.every((point) => Utils.pointInBounds(point, bounds)) ||
Intersect.polyline.bounds(rotatedCorners, bounds).length > 0
)
}
transform(
shape: RectangleShape,
bounds: TLBounds,

View file

@ -2394,6 +2394,8 @@ export class TLDrawState extends StateManager<Data> {
}
// Start a brush session
// TODO: Don't start a brush session right away: we might
// be "maybe brushing" or "maybe double clicking"
this.startBrushSession(this.getPagePoint(info.point))
break
}
@ -2407,13 +2409,9 @@ export class TLDrawState extends StateManager<Data> {
// Unused
switch (this.appState.status.current) {
case TLDrawStatus.Idle: {
switch (this.appState.activeTool) {
case TLDrawShapeType.Text: {
// Create a text shape
this.createActiveToolShape(info.point)
break
}
}
// TODO: Create a text shape
// this.selectTool(TLDrawShapeType.Text)
// this.createActiveToolShape(info.point)
break
}
}

View file

@ -134,6 +134,7 @@ export enum TLDrawToolType {
}
export enum TLDrawShapeType {
PostIt = 'post-it',
Ellipse = 'ellipse',
Rectangle = 'rectangle',
Draw = 'draw',
@ -191,6 +192,12 @@ export interface GroupShape extends TLDrawBaseShape {
children: string[]
}
export interface PostItShape extends TLDrawBaseShape {
type: TLDrawShapeType.PostIt
size: number[]
text: string
}
export type TLDrawShape =
| RectangleShape
| EllipseShape
@ -198,6 +205,7 @@ export type TLDrawShape =
| ArrowShape
| TextShape
| GroupShape
| PostItShape
export abstract class TLDrawShapeUtil<
T extends TLDrawShape,