diff --git a/packages/core/src/components/html-container/html-container.tsx b/packages/core/src/components/html-container/html-container.tsx new file mode 100644 index 000000000..1dbd576d8 --- /dev/null +++ b/packages/core/src/components/html-container/html-container.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' + +interface HTMLContainerProps extends React.HTMLProps { + children: React.ReactNode +} + +export const HTMLContainer = React.memo( + React.forwardRef(({ children, ...rest }, ref) => { + return ( +
+ {children} +
+ ) + }) +) diff --git a/packages/core/src/components/html-container/index.ts b/packages/core/src/components/html-container/index.ts new file mode 100644 index 000000000..c87e8e9b0 --- /dev/null +++ b/packages/core/src/components/html-container/index.ts @@ -0,0 +1 @@ +export * from './html-container' diff --git a/packages/core/src/components/index.tsx b/packages/core/src/components/index.tsx index 966c00473..8ede353af 100644 --- a/packages/core/src/components/index.tsx +++ b/packages/core/src/components/index.tsx @@ -1,3 +1,4 @@ export * from './renderer' export { brushUpdater } from './brush' export * from './svg-container' +export * from './html-container' diff --git a/packages/core/src/hooks/useStyle.tsx b/packages/core/src/hooks/useStyle.tsx index 3cec52f12..cb22bc4ea 100644 --- a/packages/core/src/hooks/useStyle.tsx +++ b/packages/core/src/hooks/useStyle.tsx @@ -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)); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 153268c98..2b72e9d4c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 = Partial<{ [P in keyof T]: T | Partial | Patch }> @@ -304,16 +305,6 @@ export abstract class TLShapeUtil): Partial - - transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo): Partial { - return this.transform(shape, bounds, info) - } - shouldRender(_prev: T, _next: T): boolean { return true } @@ -356,6 +347,14 @@ export abstract class TLShapeUtil): Partial | void { + return undefined + } + + transformSingle(shape: T, bounds: TLBounds, info: TLTransformInfo): Partial | void { + return this.transform(shape, bounds, info) + } + updateChildren(shape: T, children: K[]): Partial[] | void { return } @@ -409,6 +408,40 @@ export abstract class TLShapeUtil | 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 -------------------- */ diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index 8709a13c5..d7b267f8f 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -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]) diff --git a/packages/tldraw/src/shape/shape-utils.tsx b/packages/tldraw/src/shape/shape-utils.tsx index ee775a5a9..1f30a9c72 100644 --- a/packages/tldraw/src/shape/shape-utils.tsx +++ b/packages/tldraw/src/shape/shape-utils.tsx @@ -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 = TLDrawShapeUtils[T] diff --git a/packages/tldraw/src/shape/shapes/index.ts b/packages/tldraw/src/shape/shapes/index.ts index 559540f9a..7430dd01e 100644 --- a/packages/tldraw/src/shape/shapes/index.ts +++ b/packages/tldraw/src/shape/shapes/index.ts @@ -4,3 +4,4 @@ export * from './rectangle' export * from './ellipse' export * from './text' export * from './group' +export * from './post-it' diff --git a/packages/tldraw/src/shape/shapes/post-it/index.ts b/packages/tldraw/src/shape/shapes/post-it/index.ts new file mode 100644 index 000000000..bcc5c46a8 --- /dev/null +++ b/packages/tldraw/src/shape/shapes/post-it/index.ts @@ -0,0 +1 @@ +export * from './post-it' diff --git a/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx b/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx new file mode 100644 index 000000000..50cd911bc --- /dev/null +++ b/packages/tldraw/src/shape/shapes/post-it/post-it.spec.tsx @@ -0,0 +1,7 @@ +import { PostIt } from './post-it' + +describe('Post-It shape', () => { + it('Creates an instance', () => { + new PostIt() + }) +}) diff --git a/packages/tldraw/src/shape/shapes/post-it/post-it.tsx b/packages/tldraw/src/shape/shapes/post-it/post-it.tsx new file mode 100644 index 000000000..28656989a --- /dev/null +++ b/packages/tldraw/src/shape/shapes/post-it/post-it.tsx @@ -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 { + type = TLDrawShapeType.PostIt as const + toolType = TLDrawToolType.Bounds + canBind = true + pathCache = new WeakMap([]) + + 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>( + ({ shape, isBinding, meta, events }, ref) => { + const [count, setCount] = React.useState(0) + + return ( + +
+
e.preventDefault()}> + e.stopPropagation()} + /> + +
+
+
+ ) + } + ) + + renderIndicator(shape: PostItShape) { + const { + style, + size: [width, height], + } = shape + + const styles = getShapeStyle(style, false) + const strokeWidth = +styles.strokeWidth + + const sw = strokeWidth + + return ( + + ) + } + + 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 + ) { + 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]), + } + } +} diff --git a/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx b/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx index eacec22ff..4b55df950 100644 --- a/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx +++ b/packages/tldraw/src/shape/shapes/rectangle/rectangle.tsx @@ -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 { }) return ( - + {isBinding && ( { pointerEvents="all" /> {paths} - + ) } ) @@ -272,19 +273,6 @@ export class Rectangle extends TLDrawShapeUtil { } } - 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, diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index 5b1651002..4ce5ba894 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -2394,6 +2394,8 @@ export class TLDrawState extends StateManager { } // 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 { // 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 } } diff --git a/packages/tldraw/src/types.ts b/packages/tldraw/src/types.ts index 03ea0d9ef..0cae8619c 100644 --- a/packages/tldraw/src/types.ts +++ b/packages/tldraw/src/types.ts @@ -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,