diff --git a/examples/tldraw-example/src/develop.tsx b/examples/tldraw-example/src/develop.tsx index d5e1dd388..bb09f7158 100644 --- a/examples/tldraw-example/src/develop.tsx +++ b/examples/tldraw-example/src/develop.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as React from 'react' -import { Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw' +import { TDShapeType, Tldraw, TldrawApp, useFileSystem } from '@tldraw/tldraw' declare const window: Window & { app: TldrawApp } @@ -12,6 +12,13 @@ export default function Develop(): JSX.Element { const handleMount = React.useCallback((app: TldrawApp) => { window.app = app rTldrawApp.current = app + // app.reset() + // app.createShapes({ + // id: 'box1', + // type: TDShapeType.Rectangle, + // point: [200, 200], + // size: [200, 200], + // }) }, []) const handleSignOut = React.useCallback(() => { diff --git a/packages/core/src/components/Page/Page.tsx b/packages/core/src/components/Page/Page.tsx index 2659d89a0..f11dc66ca 100644 --- a/packages/core/src/components/Page/Page.tsx +++ b/packages/core/src/components/Page/Page.tsx @@ -53,6 +53,7 @@ export const Page = observer(function _Page _hideCloneHandles = hideCloneHandles || !utils.showCloneHandles - if (shape.handles !== undefined) { + if (shape.handles !== undefined && !_isEditing) { shapeWithHandles = shape } } @@ -76,7 +78,7 @@ export const Page = observer(function _Page ( ))} - {!hideIndicators && + {!_hideIndicators && selectedShapes.map((shape) => ( ))} - {!hideIndicators && hoveredId && hoveredId !== editingId && ( + {!_hideIndicators && hoveredId && hoveredId !== editingId && ( { isSelected: boolean isGhost?: boolean isChildOfSelected?: boolean + bounds: TLBounds meta: M onShapeChange?: TLShapeChangeHandler onShapeBlur?: TLShapeBlurHandler diff --git a/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx b/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx index d35438435..c3d9f6c89 100644 --- a/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx +++ b/packages/tldraw/src/components/TopPanel/StyleMenu/StyleMenu.tsx @@ -22,6 +22,7 @@ import { ShapeStyles, FontStyle, AlignStyle, + TDShapeType, } from '~types' import { styled } from '~styles' import { breakpoints } from '~components/breakpoints' @@ -62,42 +63,61 @@ const ALIGN_ICONS = { const themeSelector = (s: TDSnapshot) => (s.settings.isDarkMode ? 'dark' : 'light') -const showTextStylesSelector = (s: TDSnapshot) => { +const optionsSelector = (s: TDSnapshot) => { const { activeTool, currentPageId: pageId } = s.appState - const page = s.document.pages[pageId] + switch (activeTool) { + case 'select': { + const page = s.document.pages[pageId] + let hasText = false + let hasLabel = false + for (const id of s.document.pageStates[pageId].selectedIds) { + if ('text' in page.shapes[id]) hasText = true + if ('label' in page.shapes[id]) hasLabel = true + } + return hasText ? 'text' : hasLabel ? 'label' : '' + } + case TDShapeType.Text: { + return 'text' + } + case TDShapeType.Rectangle: { + return 'label' + } + case TDShapeType.Ellipse: { + return 'label' + } + case TDShapeType.Triangle: { + return 'label' + } + case TDShapeType.Arrow: { + return 'label' + } + case TDShapeType.Line: { + return 'label' + } + } - return ( - activeTool === 'text' || - s.document.pageStates[pageId].selectedIds.some((id) => 'text' in page.shapes[id]) - ) + return false } export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { const app = useTldrawApp() - const theme = app.useStore(themeSelector) - const showTextStyles = app.useStore(showTextStylesSelector) - + const options = app.useStore(optionsSelector) const currentStyle = app.useStore(currentStyleSelector) const selectedIds = app.useStore(selectedIdsSelector) - const [displayedStyle, setDisplayedStyle] = React.useState(currentStyle) const rDisplayedStyle = React.useRef(currentStyle) - React.useEffect(() => { const { appState: { currentStyle }, page, selectedIds, } = app - let commonStyle = {} as ShapeStyles - if (selectedIds.length <= 0) { commonStyle = currentStyle } else { const overrides = new Set([]) - app.selectedIds .map((id) => page.shapes[id]) .forEach((shape) => { @@ -117,7 +137,6 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { }) }) } - // Until we can work out the correct logic for deciding whether or not to // update the selected style, do a string comparison. Yuck! if (JSON.stringify(commonStyle) !== JSON.stringify(rDisplayedStyle.current)) { @@ -125,34 +144,27 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { setDisplayedStyle(commonStyle) } }, [currentStyle, selectedIds]) - const handleToggleFilled = React.useCallback((checked: boolean) => { app.style({ isFilled: checked }) }, []) - const handleDashChange = React.useCallback((value: string) => { app.style({ dash: value as DashStyle }) }, []) - const handleSizeChange = React.useCallback((value: string) => { app.style({ size: value as SizeStyle }) }, []) - const handleFontChange = React.useCallback((value: string) => { app.style({ font: value as FontStyle }) }, []) - const handleTextAlignChange = React.useCallback((value: string) => { app.style({ textAlign: value as AlignStyle }) }, []) - const handleMenuOpenChange = React.useCallback( (open: boolean) => { app.setMenuOpen(open) }, [app] ) - return ( @@ -237,7 +249,7 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { ))} - {showTextStyles && ( + {(options === 'text' || options === 'label') && ( <> @@ -256,26 +268,28 @@ export const StyleMenu = React.memo(function ColorMenu(): JSX.Element { ))} - - Align - - {Object.values(AlignStyle).map((style) => ( - - {ALIGN_ICONS[style]} - - ))} - - + {options === 'text' && ( + + Align + + {Object.values(AlignStyle).map((style) => ( + + {ALIGN_ICONS[style]} + + ))} + + + )} )} diff --git a/packages/tldraw/src/constants.ts b/packages/tldraw/src/constants.ts index 02a9124ae..653fb500d 100644 --- a/packages/tldraw/src/constants.ts +++ b/packages/tldraw/src/constants.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +export const LETTER_SPACING = '-0.03em' export const GRID_SIZE = 8 export const SVG_EXPORT_PADDING = 16 export const BINDING_DISTANCE = 16 @@ -10,6 +11,7 @@ export const SLOW_SPEED = 10 export const VERY_SLOW_SPEED = 2.5 export const GHOSTED_OPACITY = 0.3 export const DEAD_ZONE = 3 +export const LABEL_POINT = [0.5, 0.5] import type { Easing } from '~types' diff --git a/packages/tldraw/src/hooks/useStylesheet.ts b/packages/tldraw/src/hooks/useStylesheet.ts index 9e46c38a0..ab7b7c34c 100644 --- a/packages/tldraw/src/hooks/useStylesheet.ts +++ b/packages/tldraw/src/hooks/useStylesheet.ts @@ -4,7 +4,7 @@ const styles = new Map() const UID = `Tldraw-fonts` const CSS = ` -@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Source+Serif+Pro'); ` export function useStylesheet() { diff --git a/packages/tldraw/src/state/TldrawApp.spec.ts b/packages/tldraw/src/state/TldrawApp.spec.ts index 07a420f80..fcea083ea 100644 --- a/packages/tldraw/src/state/TldrawApp.spec.ts +++ b/packages/tldraw/src/state/TldrawApp.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { mockDocument, TldrawTestApp } from '~test' -import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types' +import { ArrowShape, ColorStyle, RectangleShape, SessionType, TDShapeType } from '~types' import type { SelectTool } from './tools/SelectTool' describe('TldrawTestApp', () => { diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index d2d379fc3..009627e82 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -3364,7 +3364,7 @@ export class TldrawApp extends StateManager { getShapeUtil = TLDR.getShapeUtil - static version = 15.2 + static version = 15.3 static defaultDocument: TDDocument = { id: 'doc', diff --git a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap index bff5836f4..a2ed01405 100644 --- a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap +++ b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap @@ -20,7 +20,12 @@ exports[` 1`] = ` \\"dash\\": \\"draw\\", \\"size\\": \\"medium\\", \\"color\\": \\"blue\\" - } + }, + \\"label\\": \\"\\", + \\"labelPoint\\": [ + 0.5, + 0.5 + ] }, { \\"id\\": \\"rect2\\", @@ -40,7 +45,12 @@ exports[` 1`] = ` \\"dash\\": \\"draw\\", \\"size\\": \\"medium\\", \\"color\\": \\"blue\\" - } + }, + \\"label\\": \\"\\", + \\"labelPoint\\": [ + 0.5, + 0.5 + ] }, { \\"id\\": \\"rect3\\", @@ -60,7 +70,12 @@ exports[` 1`] = ` \\"dash\\": \\"draw\\", \\"size\\": \\"medium\\", \\"color\\": \\"blue\\" - } + }, + \\"label\\": \\"\\", + \\"labelPoint\\": [ + 0.5, + 0.5 + ] } ]" `; @@ -84,6 +99,11 @@ Array [ "rect1": Object { "childIndex": 1, "id": "rect1", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Rectangle", "parentId": "page1", "point": Array [ @@ -145,6 +165,11 @@ Array [ "rect2": Object { "childIndex": 1, "id": "rect2", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Rectangle", "parentId": "page1", "point": Array [ diff --git a/packages/tldraw/src/state/data/migrate.ts b/packages/tldraw/src/state/data/migrate.ts index 7b5486520..1f343aee6 100644 --- a/packages/tldraw/src/state/data/migrate.ts +++ b/packages/tldraw/src/state/data/migrate.ts @@ -72,15 +72,27 @@ export function migrate(document: TDDocument, newVersion: number): TDDocument { document.assets = {} } - if (version < 15.2) { - Object.values(document.pages).forEach((page) => { - Object.values(page.shapes).forEach((shape) => { + Object.values(document.pages).forEach((page) => { + Object.values(page.shapes).forEach((shape) => { + if (version < 15.2) { if (shape.type === TDShapeType.Image || shape.type === TDShapeType.Video) { shape.style.isFilled = true } - }) + } + + if (version < 15.3) { + if ( + shape.type === TDShapeType.Rectangle || + shape.type === TDShapeType.Triangle || + shape.type === TDShapeType.Ellipse || + shape.type === TDShapeType.Arrow + ) { + shape.label = (shape as any).text || '' + shape.labelPoint = [0.5, 0.5] + } + } }) - } + }) // Cleanup Object.values(document.pageStates).forEach((pageState) => { diff --git a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts index 2bfd1bbfb..8c2abacd3 100644 --- a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts +++ b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.spec.ts @@ -212,6 +212,8 @@ describe('When creating with an arrow session', () => { it('Removes a binding when dragged away', () => { const app = new TldrawTestApp() + .selectAll() + .delete() .createShapes( { type: TDShapeType.Rectangle, id: 'rect1', point: [200, 200], size: [200, 200] }, { type: TDShapeType.Rectangle, id: 'rect2', point: [400, 200], size: [200, 200] }, @@ -260,7 +262,7 @@ describe('When drawing an arrow', () => { expect(app.shapes.length).toBe(1) }) - it('creates a short arrow if at least one handle is bound to a shape', () => { + it('create a short arrow if at least one handle is bound to a shape', () => { const app = new TldrawTestApp() .createShapes({ type: TDShapeType.Rectangle, @@ -292,7 +294,7 @@ describe('When drawing an arrow', () => { expect(app.shapes.length).toBe(1) }) - it('creates a short arrow if start handle is bound', () => { + it('create a short arrow if start handle is bound', () => { const app = new TldrawTestApp() .createShapes({ type: TDShapeType.Rectangle, diff --git a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts index f450fa633..164d32d57 100644 --- a/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts +++ b/packages/tldraw/src/state/sessions/ArrowSession/ArrowSession.ts @@ -14,7 +14,7 @@ import { TLDR } from '~state/TLDR' import { shapeUtils } from '~state/shapes' import { BaseSession } from '../BaseSession' import type { TldrawApp } from '../../internal' -import { TLPerformanceMode, Utils } from '@tldraw/core' +import { Utils } from '@tldraw/core' export class ArrowSession extends BaseSession { type = SessionType.Arrow @@ -40,9 +40,13 @@ export class ArrowSession extends BaseSession { this.bindableShapeIds = TLDR.getBindableShapeIds(app.state).filter( (id) => !(id === this.initialShape.id || id === this.initialShape.parentId) ) - + const oppositeHandleBindingId = + this.initialShape.handles[handleId === 'start' ? 'end' : 'start']?.bindingId + if (oppositeHandleBindingId) { + const oppositeToId = page.bindings[oppositeHandleBindingId].toId + this.bindableShapeIds = this.bindableShapeIds.filter((id) => id !== oppositeToId) + } const { originPoint } = this.app - if (this.isCreate) { // If we're creating a new shape, should we bind its first point? // The method may return undefined, which is correct if there is no @@ -52,11 +56,13 @@ export class ArrowSession extends BaseSession { .find((shape) => Utils.pointInBounds(originPoint, TLDR.getShapeUtil(shape).getBounds(shape)) )?.id + if (this.startBindingShapeId) { + this.bindableShapeIds.splice(this.bindableShapeIds.indexOf(this.startBindingShapeId), 1) + } } else { // If we're editing an existing line, is there a binding already // for the dragging handle? const initialBindingId = this.initialShape.handles[this.handleId].bindingId - if (initialBindingId) { this.initialBinding = page.bindings[initialBindingId] } else { @@ -78,22 +84,15 @@ export class ArrowSession extends BaseSession { currentGrid, settings: { showGrid }, } = this.app - const shape = this.app.getShape(initialShape.id) - if (shape.isLocked) return - const handles = shape.handles - const handleId = this.handleId as keyof typeof handles - // If the handle can bind, then we need to search bindable shapes for // a binding. if (!handles[handleId].canBind) return - // First update the handle's next point let delta = Vec.sub(currentPoint, handles[handleId].point) - if (shiftKey) { const A = handles[handleId === 'start' ? 'end' : 'start'].point const B = handles[handleId].point @@ -102,25 +101,18 @@ export class ArrowSession extends BaseSession { const adjusted = Vec.rotWith(C, A, Utils.snapAngleToSegments(angle, 24) - angle) delta = Vec.add(delta, Vec.sub(adjusted, C)) } - const nextPoint = Vec.sub(Vec.add(handles[handleId].point, delta), shape.point) - const handle = { ...handles[handleId], point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint), - bindingId: undefined, } - const utils = shapeUtils[TDShapeType.Arrow] - const change = utils.onHandleChange?.(shape, { [handleId]: handle, }) - // If the handle changed produced no change, bail here if (!change) return - // If nothing changes, we want these to be the same object reference as // before. If it does change, we'll redefine this later on. And if we've // made it this far, the shape should be a new object reference that @@ -129,29 +121,23 @@ export class ArrowSession extends BaseSession { shape: Utils.deepMerge(shape, change), bindings: {}, } - if (this.initialBinding) { next.bindings[this.initialBinding.id] = undefined } - // START BINDING - // If we have a start binding shape id, the recompute the binding // point based on the current end handle position if (this.startBindingShapeId) { let startBinding: ArrowBinding | undefined - const target = this.app.page.shapes[this.startBindingShapeId] - const targetUtils = TLDR.getShapeUtil(target) - if (!metaKey) { const center = targetUtils.getCenter(target) const handle = next.shape.handles.start const rayPoint = Vec.add(handle.point, next.shape.point) const rayOrigin = center const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin)) - + const isInsideShape = targetUtils.hitTestPoint(target, currentPoint) startBinding = this.findBindingPoint( shape, target, @@ -160,15 +146,12 @@ export class ArrowSession extends BaseSession { center, rayOrigin, rayDirection, - false + isInsideShape ) } - if (startBinding) { this.didBind = true - next.bindings[this.newStartBindingId] = startBinding - next.shape.handles = { ...next.shape.handles, start: { @@ -176,11 +159,8 @@ export class ArrowSession extends BaseSession { bindingId: startBinding.id, }, } - const target = this.app.page.shapes[this.startBindingShapeId] - const targetUtils = TLDR.getShapeUtil(target) - const arrowChange = TLDR.getShapeUtil(next.shape.type).onBindingChange?.( next.shape, startBinding, @@ -189,17 +169,12 @@ export class ArrowSession extends BaseSession { targetUtils.getExpandedBounds(target), targetUtils.getCenter(target) ) - - if (arrowChange) { - Object.assign(next.shape, arrowChange) - } + if (arrowChange) Object.assign(next.shape, arrowChange) } else { this.didBind = this.didBind || false - if (this.app.page.bindings[this.newStartBindingId]) { next.bindings[this.newStartBindingId] = undefined } - if (shape.handles.start.bindingId === this.newStartBindingId) { next.shape.handles = { ...next.shape.handles, @@ -211,20 +186,15 @@ export class ArrowSession extends BaseSession { } } } - // DRAGGED POINT BINDING - let draggedBinding: ArrowBinding | undefined - if (!metaKey) { const handle = next.shape.handles[this.handleId] const oppositeHandle = next.shape.handles[this.handleId === 'start' ? 'end' : 'start'] const rayOrigin = Vec.add(oppositeHandle.point, next.shape.point) const rayPoint = Vec.add(handle.point, next.shape.point) const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin)) - const targets = this.bindableShapeIds.map((id) => this.app.page.shapes[id]) - for (const target of targets) { draggedBinding = this.findBindingPoint( shape, @@ -236,16 +206,12 @@ export class ArrowSession extends BaseSession { rayDirection, altKey ) - if (draggedBinding) break } } - if (draggedBinding) { this.didBind = true - next.bindings[this.draggedBindingId] = draggedBinding - next.shape.handles = { ...next.shape.handles, [this.handleId]: { @@ -253,13 +219,9 @@ export class ArrowSession extends BaseSession { bindingId: this.draggedBindingId, }, } - const target = this.app.page.shapes[draggedBinding.toId] - const targetUtils = TLDR.getShapeUtil(target) - const utils = shapeUtils[TDShapeType.Arrow] - const arrowChange = utils.onBindingChange( next.shape, draggedBinding, @@ -268,21 +230,17 @@ export class ArrowSession extends BaseSession { targetUtils.getExpandedBounds(target), targetUtils.getCenter(target) ) - if (arrowChange) { Object.assign(next.shape, arrowChange) } } else { this.didBind = this.didBind || false - const currentBindingId = shape.handles[this.handleId].bindingId - if (currentBindingId) { next.bindings = { ...next.bindings, [currentBindingId]: undefined, } - next.shape.handles = { ...next.shape.handles, [this.handleId]: { @@ -292,7 +250,6 @@ export class ArrowSession extends BaseSession { } } } - return { document: { pages: { @@ -315,12 +272,18 @@ export class ArrowSession extends BaseSession { cancel = (): TldrawPatch | undefined => { const { initialShape, initialBinding, newStartBindingId, draggedBindingId } = this + const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape + + const isDeleting = + this.isCreate || + Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4 + const afterBindings: Record = {} afterBindings[draggedBindingId] = undefined if (initialBinding) { - afterBindings[initialBinding.id] = initialBinding + afterBindings[initialBinding.id] = isDeleting ? undefined : initialBinding } if (newStartBindingId) { @@ -332,14 +295,14 @@ export class ArrowSession extends BaseSession { pages: { [this.app.currentPageId]: { shapes: { - [initialShape.id]: this.isCreate ? undefined : initialShape, + [initialShape.id]: isDeleting ? undefined : initialShape, }, bindings: afterBindings, }, }, pageStates: { [this.app.currentPageId]: { - selectedIds: this.isCreate ? [] : [initialShape.id], + selectedIds: isDeleting ? [] : [initialShape.id], bindingId: undefined, hoveredId: undefined, editingId: undefined, @@ -351,36 +314,24 @@ export class ArrowSession extends BaseSession { complete = (): TldrawPatch | TldrawCommand | undefined => { const { initialShape, initialBinding, newStartBindingId, startBindingShapeId, handleId } = this - const currentShape = TLDR.onSessionComplete(this.app.page.shapes[initialShape.id]) as ArrowShape const currentBindingId = currentShape.handles[handleId].bindingId - - if ( - !(currentBindingId || initialBinding) && - Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) < 4 - ) { - return this.cancel() - } - + const length = Vec.dist(currentShape.handles.start.point, currentShape.handles.end.point) + if (!(currentBindingId || initialBinding) && length < 4) return this.cancel() const beforeBindings: Partial> = {} - const afterBindings: Partial> = {} - if (initialBinding) { beforeBindings[initialBinding.id] = this.isCreate ? undefined : initialBinding afterBindings[initialBinding.id] = undefined } - if (currentBindingId) { beforeBindings[currentBindingId] = undefined afterBindings[currentBindingId] = this.app.page.bindings[currentBindingId] } - if (startBindingShapeId) { beforeBindings[newStartBindingId] = undefined afterBindings[newStartBindingId] = this.app.page.bindings[newStartBindingId] } - return { id: 'arrow', before: { diff --git a/packages/tldraw/src/state/sessions/HandleSession/HandleSession.ts b/packages/tldraw/src/state/sessions/HandleSession/HandleSession.ts index 82dc6fd95..55f8c7885 100644 --- a/packages/tldraw/src/state/sessions/HandleSession/HandleSession.ts +++ b/packages/tldraw/src/state/sessions/HandleSession/HandleSession.ts @@ -48,7 +48,6 @@ export class HandleSession extends BaseSession { } // First update the handle's next point - const change = TLDR.getShapeUtil(shape).onHandleChange?.( shape, { diff --git a/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.spec.ts b/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.spec.ts new file mode 100644 index 000000000..6d8e0eabb --- /dev/null +++ b/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.spec.ts @@ -0,0 +1,7 @@ +import { mockDocument, TldrawTestApp } from '~test' +import { SessionType, TDShapeType, TDStatus } from '~types' + +describe('Translate label session', () => { + it.todo('begins, updateSession') + it.todo('cancels session') +}) diff --git a/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.ts b/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.ts new file mode 100644 index 000000000..f76e870f9 --- /dev/null +++ b/packages/tldraw/src/state/sessions/TranslateLabelSession/TranslateLabelSession.ts @@ -0,0 +1,112 @@ +import { Vec } from '@tldraw/vec' +import { + SessionType, + ShapesWithProp, + TldrawCommand, + TldrawPatch, + TDStatus, + RectangleShape, + TriangleShape, + EllipseShape, + ArrowShape, +} from '~types' +import { TLDR } from '~state/TLDR' +import { BaseSession } from '../BaseSession' +import type { TldrawApp } from '../../internal' +import type { TLBounds } from '@tldraw/core' + +export class TranslateLabelSession extends BaseSession { + type = SessionType.Handle + performanceMode = undefined + status = TDStatus.TranslatingHandle + initialShape: RectangleShape | TriangleShape | EllipseShape | ArrowShape + initialShapeBounds: TLBounds + + constructor(app: TldrawApp, shapeId: string) { + super(app) + this.initialShape = this.app.getShape(shapeId) + this.initialShapeBounds = this.app.getShapeBounds(shapeId) + } + + start = (): TldrawPatch | undefined => void null + + update = (): TldrawPatch | undefined => { + const { + initialShapeBounds, + app: { currentPageId, currentPoint }, + } = this + + const newHandlePoint = [ + Math.max(0, Math.min(1, currentPoint[0] / initialShapeBounds.width)), + Math.max(0, Math.min(1, currentPoint[1] / initialShapeBounds.height)), + ] + + // First update the handle's next point + const change = { + handlePoint: newHandlePoint, + } as Partial + + return { + document: { + pages: { + [currentPageId]: { + shapes: { + [this.initialShape.id]: change, + }, + }, + }, + }, + } + } + + cancel = (): TldrawPatch | undefined => { + const { + initialShape, + app: { currentPageId }, + } = this + + return { + document: { + pages: { + [currentPageId]: { + shapes: { + [initialShape.id]: initialShape, + }, + }, + }, + }, + } + } + + complete = (): TldrawPatch | TldrawCommand | undefined => { + const { + initialShape, + app: { currentPageId }, + } = this + + return { + before: { + document: { + pages: { + [currentPageId]: { + shapes: { + [initialShape.id]: initialShape, + }, + }, + }, + }, + }, + after: { + document: { + pages: { + [currentPageId]: { + shapes: { + [initialShape.id]: TLDR.onSessionComplete(this.app.getShape(this.initialShape.id)), + }, + }, + }, + }, + }, + } + } +} diff --git a/packages/tldraw/src/state/sessions/TranslateLabelSession/index.ts b/packages/tldraw/src/state/sessions/TranslateLabelSession/index.ts new file mode 100644 index 000000000..a27afbddb --- /dev/null +++ b/packages/tldraw/src/state/sessions/TranslateLabelSession/index.ts @@ -0,0 +1 @@ +export * from './TranslateLabelSession' diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx b/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx index e6d610c02..ae6515f04 100644 --- a/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx +++ b/packages/tldraw/src/state/shapes/ArrowUtil/ArrowUtil.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { Utils, TLBounds, SVGContainer } from '@tldraw/core' import { Vec } from '@tldraw/vec' -import { defaultStyle, getShapeStyle } from '../shared/shape-styles' +import { defaultStyle } from '../shared/shape-styles' import { ArrowShape, TransformInfo, @@ -22,30 +22,32 @@ import { intersectRayEllipse, intersectRayLineSegment, } from '@tldraw/intersect' -import { BINDING_DISTANCE, EASINGS, GHOSTED_OPACITY } from '~constants' +import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants' import { + getArcLength, getArcPoints, - getArrowArc, - getArrowArcPath, getArrowPath, getBendPoint, getCtp, - getCurvedArrowHeadPoints, - getStraightArrowHeadPoints, isAngleBetween, - renderCurvedFreehandArrowShaft, - renderFreehandArrowShaft, } from './arrowHelpers' -import { getTrianglePoints } from '../TriangleUtil' +import { getTrianglePoints } from '../TriangleUtil/triangleHelpers' +import { styled } from '~styles' +import { TextLabel, getFontStyle } from '../shared' +import { getTextLabelSize } from '../shared/getTextSize' +import { StraightArrow } from './components/StraightArrow' +import { CurvedArrow } from './components/CurvedArrow.tsx' type T = ArrowShape -type E = SVGSVGElement +type E = HTMLDivElement export class ArrowUtil extends TDShapeUtil { type = TDShapeType.Arrow as const hideBounds = true + canEdit = true + pathCache = new WeakMap() getShape = (props: Partial): T => { @@ -88,194 +90,145 @@ export class ArrowUtil extends TDShapeUtil { isFilled: false, ...props.style, }, + label: '', + labelPoint: [0.5, 0.5], ...props, } } - Component = TDShapeUtil.Component(({ shape, isGhost, meta, events }, ref) => { - const { - handles: { start, bend, end }, - decorations = {}, - style, - } = shape - - const isDraw = style.dash === DashStyle.Draw - - const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1 - - const styles = getShapeStyle(style, meta.isDarkMode) - - const { strokeWidth } = styles - - const arrowDist = Vec.dist(start.point, end.point) - - const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8) - - const sw = 1 + strokeWidth * 1.618 - - let shaftPath: JSX.Element | null - let startArrowHead: { left: number[]; right: number[] } | undefined - let endArrowHead: { left: number[]; right: number[] } | undefined - - const getRandom = Utils.rng(shape.id) - - const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic'] - - if (isStraightLine) { - const path = isDraw - ? renderFreehandArrowShaft(shape) - : 'M' + Vec.toFixed(start.point) + 'L' + Vec.toFixed(end.point) - - const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( - arrowDist, - strokeWidth * 1.618, - shape.style.dash, - 2, - false + Component = TDShapeUtil.Component( + ({ shape, isEditing, isGhost, meta, events, onShapeChange, onShapeBlur }, ref) => { + const { + id, + label = '', + handles: { start, bend, end }, + decorations = {}, + style, + } = shape + const isStraightLine = Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1 + const font = getFontStyle(style) + const labelSize = label || isEditing ? getTextLabelSize(label, font) : [0, 0] + const bounds = this.getBounds(shape) + const dist = React.useMemo(() => { + const { start, bend, end } = shape.handles + if (isStraightLine) return Vec.dist(start.point, end.point) + const circle = getCtp(start.point, bend.point, end.point) + const center = circle.slice(0, 2) + const radius = circle[2] + const length = getArcLength(center, radius, start.point, end.point) + return Math.abs(length) + }, [shape.handles]) + const scale = Math.max( + 0.5, + Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128))) ) - - if (decorations.start) { - startArrowHead = getStraightArrowHeadPoints(start.point, end.point, arrowHeadLength) - } - - if (decorations.end) { - endArrowHead = getStraightArrowHeadPoints(end.point, start.point, arrowHeadLength) - } - - // Straight arrow path - shaftPath = - arrowDist > 2 ? ( - <> - - - - ) : null - } else { - const circle = getCtp(shape) - - const { center, radius, length } = getArrowArc(shape) - - const path = isDraw - ? renderCurvedFreehandArrowShaft(shape, circle, length, easing) - : getArrowArcPath(start, end, circle, shape.bend) - - const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( - Math.abs(length), - sw, - shape.style.dash, - 2, - false + const offset = React.useMemo(() => { + const bounds = this.getBounds(shape) + const offset = Vec.sub(shape.handles.bend.point, [bounds.width / 2, bounds.height / 2]) + return offset + }, [shape, scale]) + const handleLabelChange = React.useCallback( + (label: string) => { + onShapeChange?.({ id, label }) + }, + [onShapeChange] ) - - if (decorations.start) { - startArrowHead = getCurvedArrowHeadPoints( - start.point, - arrowHeadLength, - center, - radius, - length < 0 - ) - } - - if (decorations.end) { - endArrowHead = getCurvedArrowHeadPoints( - end.point, - arrowHeadLength, - center, - radius, - length >= 0 - ) - } - - // Curved arrow path - shaftPath = ( - <> - - + - + + + + + + + + + + + + ) } - - return ( - - - {shaftPath} - {startArrowHead && ( - <> - - - - )} - {endArrowHead && ( - <> - - - - )} - - - ) - }) + ) Indicator = TDShapeUtil.Indicator(({ shape }) => { - return + const { + style, + decorations, + handles: { start, bend, end }, + } = shape + return ( + + ) }) getBounds = (shape: T) => { const bounds = Utils.getFromCache(this.boundsCache, shape, () => { - return Utils.getBoundsFromPoints(getArcPoints(shape)) + const { + handles: { start, bend, end }, + } = shape + return Utils.getBoundsFromPoints(getArcPoints(start.point, bend.point, end.point)) }) - return Utils.translateBounds(bounds, shape.point) } getRotatedBounds = (shape: T) => { - let points = getArcPoints(shape) - + const { + handles: { start, bend, end }, + } = shape + let points = getArcPoints(start.point, bend.point, end.point) const { minX, minY, maxX, maxY } = Utils.getBoundsFromPoints(points) - if (shape.rotation !== 0) { points = points.map((pt) => Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], shape.rotation || 0) @@ -294,56 +247,52 @@ export class ArrowUtil extends TDShapeUtil { return ( next.decorations !== prev.decorations || next.handles !== prev.handles || - next.style !== prev.style + next.style !== prev.style || + next.label !== prev.label ) } hitTestPoint = (shape: T, point: number[]): boolean => { + const { + handles: { start, bend, end }, + } = shape const pt = Vec.sub(point, shape.point) - const points = getArcPoints(shape) - + const points = getArcPoints(start.point, bend.point, end.point) for (let i = 1; i < points.length; i++) { if (Vec.distanceToLineSegment(points[i - 1], points[i], pt) < 1) { return true } } - return false } hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => { + const { + handles: { start, bend, end }, + } = shape const ptA = Vec.sub(A, shape.point) const ptB = Vec.sub(B, shape.point) - - const points = getArcPoints(shape) - + const points = getArcPoints(start.point, bend.point, end.point) for (let i = 1; i < points.length; i++) { if (intersectLineSegmentLineSegment(points[i - 1], points[i], ptA, ptB).didIntersect) { return true } } - return false } hitTestBounds = (shape: T, bounds: TLBounds) => { const { start, end, bend } = shape.handles - const sp = Vec.add(shape.point, start.point) - const ep = Vec.add(shape.point, end.point) - if (Utils.pointInBounds(sp, bounds) || Utils.pointInBounds(ep, bounds)) { return true } - if (Vec.isEqual(Vec.med(start.point, end.point), bend.point)) { return intersectLineSegmentBounds(sp, ep, bounds).length > 0 } else { - const [cx, cy, r] = getCtp(shape) - + const [cx, cy, r] = getCtp(start.point, bend.point, end.point) const cp = Vec.add(shape.point, [cx, cy]) - return intersectArcBounds(cp, r, sp, ep, bounds).length > 0 } } @@ -354,18 +303,12 @@ export class ArrowUtil extends TDShapeUtil { { initialShape, scaleX, scaleY }: TransformInfo ): Partial => { const initialShapeBounds = this.getBounds(initialShape) - const handles: (keyof T['handles'])[] = ['start', 'end'] - const nextHandles = { ...initialShape.handles } - handles.forEach((handle) => { const [x, y] = nextHandles[handle].point - const nw = x / initialShapeBounds.width - const nh = y / initialShapeBounds.height - nextHandles[handle] = { ...nextHandles[handle], point: [ @@ -374,24 +317,16 @@ export class ArrowUtil extends TDShapeUtil { ], } }) - const { start, bend, end } = nextHandles - const dist = Vec.dist(start.point, end.point) - const midPoint = Vec.med(start.point, end.point) - const bendDist = (dist / 2) * initialShape.bend - const u = Vec.uni(Vec.vec(start.point, end.point)) - const point = Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist)) - nextHandles['bend'] = { ...bend, point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point), } - return { point: Vec.toFixed([bounds.minX, bounds.minY]), handles: nextHandles, @@ -442,7 +377,6 @@ export class ArrowUtil extends TDShapeUtil { center: number[] ): Partial | void => { const handle = shape.handles[binding.handleId as keyof ArrowShape['handles']] - let handlePoint = Vec.sub( Vec.add( [expandedBounds.minX, expandedBounds.minY], @@ -453,19 +387,15 @@ export class ArrowUtil extends TDShapeUtil { ), shape.point ) - if (binding.distance) { const intersectBounds = Utils.expandBounds(targetBounds, binding.distance) - // The direction vector starts from the arrow's opposite handle const origin = Vec.add( shape.point, shape.handles[handle.id === 'start' ? 'end' : 'start'].point ) - // And passes through the dragging handle const direction = Vec.uni(Vec.sub(Vec.add(handlePoint, shape.point), origin)) - if (target.type === TDShapeType.Ellipse) { const hits = intersectRayEllipse( origin, @@ -475,45 +405,32 @@ export class ArrowUtil extends TDShapeUtil { (target as EllipseShape).radius[1] + binding.distance, 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) - } + if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point) } else if (target.type === TDShapeType.Triangle) { - const points = getTrianglePoints(target, BINDING_DISTANCE, target.rotation).map((pt) => + const points = getTrianglePoints(target.size, BINDING_DISTANCE, target.rotation).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) - } + if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point) } else { let hits = intersectRayBounds(origin, direction, intersectBounds, target.rotation) .filter((int) => int.didIntersect) .map((int) => int.points[0]) .sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin)) - if (hits.length < 2) { hits = intersectRayBounds(origin, Vec.neg(direction), intersectBounds) .filter((int) => int.didIntersect) .map((int) => int.points[0]) .sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin)) } - - if (hits[0]) { - handlePoint = Vec.sub(hits[0], shape.point) - } + if (hits[0]) handlePoint = Vec.sub(hits[0], shape.point) } } - return this.onHandleChange(shape, { [handle.id]: { ...handle, @@ -525,7 +442,6 @@ export class ArrowUtil extends TDShapeUtil { onHandleChange = (shape: T, handles: Partial): Partial | void => { let nextHandles = Utils.deepMerge(shape.handles, handles) let nextBend = shape.bend - nextHandles = { ...nextHandles, start: { @@ -537,38 +453,27 @@ export class ArrowUtil extends TDShapeUtil { point: Vec.toFixed(nextHandles.end.point), }, } - // If the user is moving the bend handle, we want to move the bend point if ('bend' in handles) { const { start, end, bend } = nextHandles - const distance = Vec.dist(start.point, end.point) - const midPoint = Vec.med(start.point, end.point) - const angle = Vec.angle(start.point, end.point) - const u = Vec.uni(Vec.vec(start.point, end.point)) - // Create a line segment perendicular to the line between the start and end points const ap = Vec.add(midPoint, Vec.mul(Vec.per(u), distance / 2)) const bp = Vec.sub(midPoint, Vec.mul(Vec.per(u), distance / 2)) - const bendPoint = Vec.nearestPointOnLineSegment(ap, bp, bend.point, true) - // Find the distance between the midpoint and the nearest point on the // line segment to the bend handle's dragged point const bendDist = Vec.dist(midPoint, bendPoint) - // The shape's "bend" is the ratio of the bend to the distance between // the start and end points. If the bend is below a certain amount, the // bend should be zero. nextBend = Utils.clamp(bendDist / (distance / 2), -0.99, 0.99) - // If the point is to the left of the line segment, we make the bend // negative, otherwise it's positive. const angleToBend = Vec.angle(start.point, bendPoint) - // If resulting bend is low enough that the handle will snap to center, // then also snap the bend to center if (Vec.isEqual(midPoint, getBendPoint(nextHandles, nextBend))) { @@ -578,7 +483,6 @@ export class ArrowUtil extends TDShapeUtil { nextBend *= -1 } } - const nextShape = { point: shape.point, bend: nextBend, @@ -590,24 +494,19 @@ export class ArrowUtil extends TDShapeUtil { }, }, } - // Zero out the handles to prevent handles with negative points. If a handle's x or y // is below zero, we need to move the shape left or up to make it zero. - const topLeft = shape.point - const nextBounds = this.getBounds({ ...nextShape } as ArrowShape) - const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft) - if (!Vec.isEqual(offset, [0, 0])) { Object.values(nextShape.handles).forEach((handle) => { handle.point = Vec.toFixed(Vec.sub(handle.point, offset)) }) - nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset)) } - return nextShape } } + +const FullWrapper = styled('div', { width: '100%', height: '100%' }) diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/__snapshots__/ArrowUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/ArrowUtil/__snapshots__/ArrowUtil.spec.tsx.snap index 2fec41753..227742592 100644 --- a/packages/tldraw/src/state/shapes/ArrowUtil/__snapshots__/ArrowUtil.spec.tsx.snap +++ b/packages/tldraw/src/state/shapes/ArrowUtil/__snapshots__/ArrowUtil.spec.tsx.snap @@ -36,6 +36,11 @@ Object { }, }, "id": "arrow", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Arrow", "parentId": "page", "point": Array [ diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/arrowHelpers.ts b/packages/tldraw/src/state/shapes/ArrowUtil/arrowHelpers.ts index f00d7892c..b76e7653a 100644 --- a/packages/tldraw/src/state/shapes/ArrowUtil/arrowHelpers.ts +++ b/packages/tldraw/src/state/shapes/ArrowUtil/arrowHelpers.ts @@ -4,27 +4,22 @@ import Vec from '@tldraw/vec' import getStroke from 'perfect-freehand' import { EASINGS } from '~constants' import { getShapeStyle } from '../shared/shape-styles' -import type { ArrowShape, TldrawHandle } from '~types' +import type { ArrowShape, Decoration, ShapeStyles } from '~types' import { TLDR } from '../../TLDR' -export function getArrowArcPath( - start: TldrawHandle, - end: TldrawHandle, - circle: number[], - bend: number -) { +export function getArrowArcPath(start: number[], end: number[], circle: number[], bend: number) { return [ 'M', - start.point[0], - start.point[1], + start[0], + start[1], 'A', circle[2], circle[2], 0, 0, bend < 0 ? 0 : 1, - end.point[0], - end.point[1], + end[0], + end[1], ].join(' ') } @@ -46,23 +41,18 @@ export function getBendPoint(handles: ArrowShape['handles'], bend: number) { return point } -export function renderFreehandArrowShaft(shape: ArrowShape) { - const { style, id } = shape - - const { start, end } = shape.handles - +export function renderFreehandArrowShaft( + id: string, + style: ShapeStyles, + start: number[], + end: number[], + decorationStart: Decoration | undefined, + decorationEnd: Decoration | undefined +) { const getRandom = Utils.rng(id) - const strokeWidth = getShapeStyle(style).strokeWidth - - const startPoint = shape.decorations?.start - ? Vec.nudge(start.point, end.point, strokeWidth) - : start.point - - const endPoint = shape.decorations?.end - ? Vec.nudge(end.point, start.point, strokeWidth) - : end.point - + const startPoint = decorationStart ? Vec.nudge(start, end, strokeWidth) : start + const endPoint = decorationEnd ? Vec.nudge(end, start, strokeWidth) : end const stroke = getStroke([startPoint, endPoint], { size: strokeWidth, thinning: 0.618 + getRandom() * 0.2, @@ -71,54 +61,34 @@ export function renderFreehandArrowShaft(shape: ArrowShape) { streamline: 0, last: true, }) - - const path = Utils.getSvgPathFromStroke(stroke) - - return path + return Utils.getSvgPathFromStroke(stroke) } export function renderCurvedFreehandArrowShaft( - shape: ArrowShape, - circle: number[], + id: string, + style: ShapeStyles, + start: number[], + end: number[], + decorationStart: Decoration | undefined, + decorationEnd: Decoration | undefined, + center: number[], + radius: number, length: number, easing: (t: number) => number ) { - const { style, id } = shape - - const { start, end } = shape.handles - const getRandom = Utils.rng(id) - const strokeWidth = getShapeStyle(style).strokeWidth - - const center = [circle[0], circle[1]] - - const radius = circle[2] - - const startPoint = shape.decorations?.start - ? Vec.rotWith(start.point, center, strokeWidth / length) - : start.point - - const endPoint = shape.decorations?.end - ? Vec.rotWith(end.point, center, -(strokeWidth / length)) - : end.point - + const startPoint = decorationStart ? Vec.rotWith(start, center, strokeWidth / length) : start + const endPoint = decorationEnd ? Vec.rotWith(end, center, -(strokeWidth / length)) : end const startAngle = Vec.angle(center, startPoint) - const endAngle = Vec.angle(center, endPoint) - const points: number[][] = [] - const count = 8 + Math.floor((Math.abs(length) / 20) * 1 + getRandom() / 2) - for (let i = 0; i < count; i++) { const t = easing(i / count) - const angle = Utils.lerpAngles(startAngle, endAngle, t) - points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius))) } - const stroke = getStroke([startPoint, ...points, endPoint], { size: 1 + strokeWidth, thinning: 0.618 + getRandom() * 0.2, @@ -127,27 +97,11 @@ export function renderCurvedFreehandArrowShaft( streamline: 0, last: true, }) - - const path = Utils.getSvgPathFromStroke(stroke) - - return path + return Utils.getSvgPathFromStroke(stroke) } -export function getCtp(shape: ArrowShape) { - const { start, end, bend } = shape.handles - return Utils.circleFromThreePoints(start.point, end.point, bend.point) -} - -export function getArrowArc(shape: ArrowShape) { - const { start, end, bend } = shape.handles - - const [cx, cy, radius] = Utils.circleFromThreePoints(start.point, end.point, bend.point) - - const center = [cx, cy] - - const length = getArcLength(center, radius, start.point, end.point) - - return { center, radius, length } +export function getCtp(start: number[], bend: number[], end: number[]) { + return Utils.circleFromThreePoints(start, end, bend) } export function getCurvedArrowHeadPoints( @@ -158,18 +112,13 @@ export function getCurvedArrowHeadPoints( sweep: boolean ) { const ints = intersectCircleCircle(A, r1 * 0.618, C, r2).points - if (!ints) { TLDR.warn('Could not find an intersection for the arrow head.') return { left: A, right: A } } - const int = sweep ? ints[0] : ints[1] - const left = int ? Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382) : A - const right = int ? Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382) : A - return { left, right } } @@ -179,13 +128,9 @@ export function getStraightArrowHeadPoints(A: number[], B: number[], r: number) TLDR.warn('Could not find an intersection for the arrow head.') return { left: A, right: A } } - const int = ints[0] - const left = int ? Vec.rotWith(int, A, Math.PI / 6) : A - const right = int ? Vec.rotWith(int, A, -Math.PI / 6) : A - return { left, right } } @@ -197,88 +142,64 @@ export function getCurvedArrowHeadPath( sweep: boolean ) { const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep) - return `M ${left} L ${A} ${right}` } export function getStraightArrowHeadPath(A: number[], B: number[], r: number) { const { left, right } = getStraightArrowHeadPoints(A, B, r) - return `M ${left} L ${A} ${right}` } -export function getArrowPath(shape: ArrowShape) { - const { - decorations, - handles: { start, end, bend: _bend }, - style, - } = shape - +export function getArrowPath( + style: ShapeStyles, + start: number[], + bend: number[], + end: number[], + decorationStart: Decoration | undefined, + decorationEnd: Decoration | undefined +) { const { strokeWidth } = getShapeStyle(style, false) - - const arrowDist = Vec.dist(start.point, end.point) - + const arrowDist = Vec.dist(start, end) const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8) - const path: (string | number)[] = [] - - const isStraightLine = Vec.dist(_bend.point, Vec.toFixed(Vec.med(start.point, end.point))) < 1 - + const isStraightLine = Vec.dist(bend, Vec.toFixed(Vec.med(start, end))) < 1 if (isStraightLine) { - // Path (line segment) - path.push(`M ${start.point} L ${end.point}`) - - // Start arrow head - if (decorations?.start) { - path.push(getStraightArrowHeadPath(start.point, end.point, arrowHeadLength)) + path.push(`M ${start} L ${end}`) + if (decorationStart) { + path.push(getStraightArrowHeadPath(start, end, arrowHeadLength)) } - - // End arrow head - if (decorations?.end) { - path.push(getStraightArrowHeadPath(end.point, start.point, arrowHeadLength)) + if (decorationEnd) { + path.push(getStraightArrowHeadPath(end, start, arrowHeadLength)) } } else { - const { center, radius, length } = getArrowArc(shape) - - // Path (arc) - path.push(`M ${start.point} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end.point}`) - - // Start Arrow head - if (decorations?.start) { - path.push(getCurvedArrowHeadPath(start.point, arrowHeadLength, center, radius, length < 0)) - } - - // End arrow head - if (decorations?.end) { - path.push(getCurvedArrowHeadPath(end.point, arrowHeadLength, center, radius, length >= 0)) + const circle = getCtp(start, bend, end) + const center = [circle[0], circle[1]] + const radius = circle[2] + const length = getArcLength(center, radius, start, end) + path.push(`M ${start} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end}`) + if (decorationStart) + path.push(getCurvedArrowHeadPath(start, arrowHeadLength, center, radius, length < 0)) + if (decorationEnd) { + path.push(getCurvedArrowHeadPath(end, arrowHeadLength, center, radius, length >= 0)) } } - return path.join(' ') } -export function getArcPoints(shape: ArrowShape) { - const { start, bend, end } = shape.handles - - if (Vec.dist2(bend.point, Vec.med(start.point, end.point)) > 4) { - const points: number[][] = [] - - // We're an arc, calculate points along the arc - const { center, radius } = getArrowArc(shape) - - const startAngle = Vec.angle(center, start.point) - - const endAngle = Vec.angle(center, end.point) - - for (let i = 1 / 20; i < 1; i += 1 / 20) { - const angle = Utils.lerpAngles(startAngle, endAngle, i) - points.push(Vec.nudgeAtAngle(center, angle, radius)) - } - - return points - } else { - return [start.point, end.point] +export function getArcPoints(start: number[], bend: number[], end: number[]) { + if (Vec.dist2(bend, Vec.med(start, end)) <= 4) return [start, end] + // The arc is curved; calculate twenty points along the arc + const points: number[][] = [] + const circle = getCtp(start, bend, end) + const center = [circle[0], circle[1]] + const radius = circle[2] + const startAngle = Vec.angle(center, start) + const endAngle = Vec.angle(center, end) + for (let i = 1 / 20; i < 1; i += 1 / 20) { + const angle = Utils.lerpAngles(startAngle, endAngle, i) + points.push(Vec.nudgeAtAngle(center, angle, radius)) } + return points } export function isAngleBetween(a: number, b: number, c: number): boolean { diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/components/ArrowHead.tsx b/packages/tldraw/src/state/shapes/ArrowUtil/components/ArrowHead.tsx new file mode 100644 index 000000000..da53a994d --- /dev/null +++ b/packages/tldraw/src/state/shapes/ArrowUtil/components/ArrowHead.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' + +export interface ArrowheadProps { + left: number[] + middle: number[] + right: number[] + stroke: string + strokeWidth: number +} + +export function Arrowhead({ left, middle, right, stroke, strokeWidth }: ArrowheadProps) { + return ( + + + + + ) +} diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/components/CurvedArrow.tsx.tsx b/packages/tldraw/src/state/shapes/ArrowUtil/components/CurvedArrow.tsx.tsx new file mode 100644 index 000000000..ab60375b0 --- /dev/null +++ b/packages/tldraw/src/state/shapes/ArrowUtil/components/CurvedArrow.tsx.tsx @@ -0,0 +1,116 @@ +import { Utils } from '@tldraw/core' +import Vec from '@tldraw/vec' +import * as React from 'react' +import { EASINGS } from '~constants' +import { getShapeStyle } from '~state/shapes/shared' +import type { Decoration, ShapeStyles } from '~types' +import { + getArcLength, + getArrowArcPath, + getCtp, + getCurvedArrowHeadPoints, + renderCurvedFreehandArrowShaft, +} from '../arrowHelpers' +import { Arrowhead } from './ArrowHead' + +interface ArrowSvgProps { + id: string + style: ShapeStyles + start: number[] + bend: number[] + end: number[] + arrowBend: number + decorationStart: Decoration | undefined + decorationEnd: Decoration | undefined + isDarkMode: boolean + isDraw: boolean +} + +export const CurvedArrow = React.memo(function CurvedArrow({ + id, + style, + start, + bend, + end, + arrowBend, + decorationStart, + decorationEnd, + isDraw, + isDarkMode, +}: ArrowSvgProps) { + const arrowDist = Vec.dist(start, end) + if (arrowDist < 2) return null + const styles = getShapeStyle(style, isDarkMode) + const { strokeWidth } = styles + const sw = 1 + strokeWidth * 1.618 + // Calculate a path as a segment of a circle passing through the three points start, bend, and end + const circle = getCtp(start, bend, end) + const center = [circle[0], circle[1]] + const radius = circle[2] + const length = getArcLength(center, radius, start, end) + const getRandom = Utils.rng(id) + const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic'] + const path = isDraw + ? renderCurvedFreehandArrowShaft( + id, + style, + start, + end, + decorationStart, + decorationEnd, + center, + radius, + length, + easing + ) + : getArrowArcPath(start, end, circle, arrowBend) + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( + Math.abs(length), + sw, + style.dash, + 2, + false + ) + // Arrowheads + const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8) + const startArrowHead = decorationStart + ? getCurvedArrowHeadPoints(start, arrowHeadLength, center, radius, length < 0) + : null + const endArrowHead = decorationEnd + ? getCurvedArrowHeadPoints(end, arrowHeadLength, center, radius, length >= 0) + : null + return ( + <> + + + {startArrowHead && ( + + )} + {endArrowHead && ( + + )} + + ) +}) diff --git a/packages/tldraw/src/state/shapes/ArrowUtil/components/StraightArrow.tsx b/packages/tldraw/src/state/shapes/ArrowUtil/components/StraightArrow.tsx new file mode 100644 index 000000000..ed630aec0 --- /dev/null +++ b/packages/tldraw/src/state/shapes/ArrowUtil/components/StraightArrow.tsx @@ -0,0 +1,90 @@ +import { Utils } from '@tldraw/core' +import Vec from '@tldraw/vec' +import * as React from 'react' +import { getShapeStyle } from '~state/shapes/shared' +import type { Decoration, ShapeStyles } from '~types' +import { getStraightArrowHeadPoints, renderFreehandArrowShaft } from '../arrowHelpers' +import { Arrowhead } from './ArrowHead' + +interface ArrowSvgProps { + id: string + style: ShapeStyles + start: number[] + bend: number[] + end: number[] + arrowBend: number + decorationStart: Decoration | undefined + decorationEnd: Decoration | undefined + isDarkMode: boolean + isDraw: boolean +} + +export const StraightArrow = React.memo(function StraightArrow({ + id, + style, + start, + end, + decorationStart, + decorationEnd, + isDraw, + isDarkMode, +}: ArrowSvgProps) { + const arrowDist = Vec.dist(start, end) + if (arrowDist < 2) return null + const styles = getShapeStyle(style, isDarkMode) + const { strokeWidth } = styles + const sw = 1 + strokeWidth * 1.618 + // Path between start and end points + const path = isDraw + ? renderFreehandArrowShaft(id, style, start, end, decorationStart, decorationEnd) + : 'M' + Vec.toFixed(start) + 'L' + Vec.toFixed(end) + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( + arrowDist, + strokeWidth * 1.618, + style.dash, + 2, + false + ) + // Arrowheads + const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8) + const startArrowHead = decorationStart + ? getStraightArrowHeadPoints(start, end, arrowHeadLength) + : null + const endArrowHead = decorationEnd + ? getStraightArrowHeadPoints(end, start, arrowHeadLength) + : null + return ( + <> + + + {startArrowHead && ( + + )} + {endArrowHead && ( + + )} + + ) +}) diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx b/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx index 66302326e..aafd69a2a 100644 --- a/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx +++ b/packages/tldraw/src/state/shapes/EllipseUtil/EllipseUtil.tsx @@ -1,19 +1,23 @@ import * as React from 'react' import { Utils, SVGContainer, TLBounds } from '@tldraw/core' import { Vec } from '@tldraw/vec' -import { defaultStyle, getShapeStyle } from '~state/shapes/shared' +import { defaultStyle, getShapeStyle, getFontStyle } from '~state/shapes/shared' import { EllipseShape, DashStyle, TDShapeType, TDShape, TransformInfo, TDMeta } from '~types' -import { GHOSTED_OPACITY } from '~constants' +import { GHOSTED_OPACITY, LABEL_POINT } from '~constants' import { TDShapeUtil } from '../TDShapeUtil' import { intersectEllipseBounds, intersectLineSegmentEllipse, intersectRayEllipse, } from '@tldraw/intersect' -import { getEllipseIndicatorPathTDSnapshot, getEllipsePath } from './ellipseHelpers' +import { getEllipseIndicatorPath } from './ellipseHelpers' +import { DrawEllipse } from './components/DrawEllipse' +import { DashedEllipse } from './components/DashedEllipse' +import { TextLabel } from '../shared/TextLabel' +import { styled } from '~styles' type T = EllipseShape -type E = SVGSVGElement +type E = HTMLDivElement type M = TDMeta export class EllipseUtil extends TDShapeUtil { @@ -21,6 +25,10 @@ export class EllipseUtil extends TDShapeUtil { canBind = true + canClone = true + + canEdit = true + getShape = (props: Partial): T => { return Utils.deepMerge( { @@ -33,132 +41,88 @@ export class EllipseUtil extends TDShapeUtil { radius: [1, 1], rotation: 0, style: defaultStyle, + label: '', + labelPoint: [0.5, 0.5], }, props ) } Component = TDShapeUtil.Component( - ({ shape, isGhost, isSelected, isBinding, meta, events }, ref) => { - const { - radius: [radiusX, radiusY], - style, - } = shape - + ( + { + shape, + isGhost, + isSelected, + isBinding, + isEditing, + meta, + bounds, + events, + onShapeChange, + onShapeBlur, + }, + ref + ) => { + const { id, radius, style, label = '', labelPoint = LABEL_POINT } = shape + const font = getFontStyle(shape.style) const styles = getShapeStyle(style, meta.isDarkMode) - 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) - - if (style.dash === DashStyle.Draw) { - const path = getEllipsePath(shape, this.getCenter(shape)) - - return ( - + const rx = Math.max(0, radius[0] - sw / 2) + const ry = Math.max(0, radius[1] - sw / 2) + const Component = style.dash === DashStyle.Draw ? DrawEllipse : DashedEllipse + const handleLabelChange = React.useCallback( + (label: string) => onShapeChange?.({ id, label }), + [onShapeChange] + ) + return ( + + + {isBinding && ( )} - - - - ) - } - - 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, - strokeWidth * 1.618, - shape.style.dash, - 4 - ) - - return ( - - {isBinding && ( - - )} - - - + ) } ) Indicator = TDShapeUtil.Indicator(({ shape }) => { - const { - radius: [radiusX, radiusY], - style, - } = shape - + const { id, radius, style } = shape 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) - + const rx = Math.max(0, radius[0] - sw / 2) + const ry = Math.max(0, radius[1] - sw / 2) return style.dash === DashStyle.Draw ? ( - + ) : ( - + ) }) @@ -224,7 +188,7 @@ export class EllipseUtil extends TDShapeUtil { } shouldRender = (prev: T, next: T): boolean => { - return next.radius !== prev.radius || next.style !== prev.style + return next.radius !== prev.radius || next.style !== prev.style || next.label !== prev.label } getCenter = (shape: T): number[] => { @@ -241,12 +205,9 @@ export class EllipseUtil extends TDShapeUtil { ) => { { const expandedBounds = this.getExpandedBounds(shape) - const center = this.getCenter(shape) - let bindingPoint: number[] let distance: number - if ( !Utils.pointInEllipse( point, @@ -254,9 +215,9 @@ export class EllipseUtil extends TDShapeUtil { shape.radius[0] + this.bindingDistance, shape.radius[1] + this.bindingDistance ) - ) + ) { return - + } if (bindAnywhere) { if (Vec.dist(point, this.getCenter(shape)) < 12) { bindingPoint = [0.5, 0.5] @@ -266,7 +227,6 @@ export class EllipseUtil extends TDShapeUtil { expandedBounds.height, ]) } - distance = 0 } else { let intersection = intersectRayEllipse( @@ -277,7 +237,6 @@ export class EllipseUtil extends TDShapeUtil { shape.radius[1], shape.rotation || 0 ).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))[0] - if (!intersection) { intersection = intersectLineSegmentEllipse( point, @@ -288,14 +247,11 @@ export class EllipseUtil extends TDShapeUtil { shape.rotation || 0 ).points.sort((a, b) => Vec.dist(a, point) - Vec.dist(b, point))[0] } - if (!intersection) { return undefined } - // The anchor is a point between the handle and the intersection const anchor = Vec.med(point, intersection) - if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) { // If we're close to the center, snap to the center bindingPoint = [0.5, 0.5] @@ -306,7 +262,6 @@ export class EllipseUtil extends TDShapeUtil { expandedBounds.height, ]) } - if ( Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0) ) { @@ -322,15 +277,10 @@ export class EllipseUtil extends TDShapeUtil { shape.radius[1], shape.rotation || 0 ).points[0] - - if (!innerIntersection) { - return undefined - } - + if (!innerIntersection) return undefined distance = Math.max(this.bindingDistance / 2, Vec.dist(point, innerIntersection)) } } - return { point: bindingPoint, distance, @@ -344,7 +294,6 @@ export class EllipseUtil extends TDShapeUtil { { scaleX, scaleY, initialShape }: TransformInfo ): Partial => { const { rotation = 0 } = initialShape - return { point: [bounds.minX, bounds.minY], radius: [bounds.width / 2, bounds.height / 2], @@ -362,3 +311,5 @@ export class EllipseUtil extends TDShapeUtil { } } } + +const FullWrapper = styled('div', { width: '100%', height: '100%' }) diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/__snapshots__/EllipseUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/EllipseUtil/__snapshots__/EllipseUtil.spec.tsx.snap index 235edbdc5..dc9171b86 100644 --- a/packages/tldraw/src/state/shapes/EllipseUtil/__snapshots__/EllipseUtil.spec.tsx.snap +++ b/packages/tldraw/src/state/shapes/EllipseUtil/__snapshots__/EllipseUtil.spec.tsx.snap @@ -4,6 +4,11 @@ exports[`Ellipse shape Creates a shape: ellipse 1`] = ` Object { "childIndex": 1, "id": "ellipse", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Ellipse", "parentId": "page", "point": Array [ diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/components/DashedEllipse.tsx b/packages/tldraw/src/state/shapes/EllipseUtil/components/DashedEllipse.tsx new file mode 100644 index 000000000..1b63e95aa --- /dev/null +++ b/packages/tldraw/src/state/shapes/EllipseUtil/components/DashedEllipse.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Utils } from '@tldraw/core' +import type { ShapeStyles } from '~types' +import { getShapeStyle } from '~state/shapes/shared' + +interface EllipseSvgProps { + radius: number[] + style: ShapeStyles + isSelected: boolean + isDarkMode: boolean +} + +export const DashedEllipse = React.memo(function DashedEllipse({ + radius, + style, + isSelected, + isDarkMode, +}: EllipseSvgProps) { + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + const sw = 1 + strokeWidth * 1.618 + const rx = Math.max(0, radius[0] - sw / 2) + const ry = Math.max(0, radius[1] - sw / 2) + const perimeter = Utils.perimeterOfEllipse(rx, ry) + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( + perimeter < 64 ? perimeter * 2 : perimeter, + strokeWidth * 1.618, + style.dash, + 4 + ) + + return ( + <> + + + + ) +}) diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/components/DrawEllipse.tsx b/packages/tldraw/src/state/shapes/EllipseUtil/components/DrawEllipse.tsx new file mode 100644 index 000000000..9915011ed --- /dev/null +++ b/packages/tldraw/src/state/shapes/EllipseUtil/components/DrawEllipse.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { getShapeStyle } from '~state/shapes/shared' +import type { ShapeStyles } from '~types' +import { getEllipseIndicatorPath, getEllipsePath } from '../ellipseHelpers' + +interface EllipseSvgProps { + id: string + radius: number[] + style: ShapeStyles + isSelected: boolean + isDarkMode: boolean +} + +export const DrawEllipse = React.memo(function DrawEllipse({ + id, + radius, + style, + isSelected, + isDarkMode, +}: EllipseSvgProps) { + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + const innerPath = getEllipsePath(id, radius, style) + + return ( + <> + + {style.isFilled && ( + + )} + + + ) +}) diff --git a/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts b/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts index 1b077b914..bcf09d5f5 100644 --- a/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts +++ b/packages/tldraw/src/state/shapes/EllipseUtil/ellipseHelpers.ts @@ -1,45 +1,26 @@ import { Utils } from '@tldraw/core' -import Vec from '@tldraw/vec' import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand' import { EASINGS } from '~constants' -import type { EllipseShape } from '~types' +import type { ShapeStyles } from '~types' import { getShapeStyle } from '../shared/shape-styles' -export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number[]) { - const { - id, - radius: [radiusX, radiusY], - point, - style, - } = shape - +export function getEllipseStrokePoints(id: string, radius: number[], style: ShapeStyles) { const { strokeWidth } = getShapeStyle(style) - const getRandom = Utils.rng(id) - - const center = Vec.sub(boundsCenter, point) - - const rx = radiusX + getRandom() * strokeWidth * 2 - const ry = radiusY + getRandom() * strokeWidth * 2 - + const rx = radius[0] + getRandom() * strokeWidth * 2 + const ry = radius[1] + getRandom() * strokeWidth * 2 const perimeter = Utils.perimeterOfEllipse(rx, ry) - const points: number[][] = [] - const start = Math.PI + Math.PI * getRandom() - const extra = Math.abs(getRandom()) - const count = Math.max(16, perimeter / 10) - for (let i = 0; i < count; i++) { const t = EASINGS.easeInOutSine(i / (count + 1)) const rads = start * 2 + Math.PI * (2 + extra) * t const c = Math.cos(rads) const s = Math.sin(rads) - points.push([rx * c + center[0], ry * s + center[1], t + 0.5 + getRandom() / 2]) + points.push([rx * c + radius[0], ry * s + radius[1], t + 0.5 + getRandom() / 2]) } - return getStrokePoints(points, { size: 1 + strokeWidth * 2, thinning: 0.618, @@ -50,24 +31,14 @@ export function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number }) } -export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) { - const { - id, - radius: [radiusX, radiusY], - style, - } = shape - +export function getEllipsePath(id: string, radius: number[], style: ShapeStyles) { const { strokeWidth } = getShapeStyle(style) - const getRandom = Utils.rng(id) - - const rx = radiusX + getRandom() * strokeWidth * 2 - const ry = radiusY + getRandom() * strokeWidth * 2 - + const rx = radius[0] + getRandom() * strokeWidth * 2 + const ry = radius[1] + getRandom() * strokeWidth * 2 const perimeter = Utils.perimeterOfEllipse(rx, ry) - return Utils.getSvgPathFromStroke( - getStrokeOutlinePoints(getEllipseStrokePoints(shape, boundsCenter), { + getStrokeOutlinePoints(getEllipseStrokePoints(id, radius, style), { size: 2 + strokeWidth * 2, thinning: 0.618, end: { taper: perimeter / 8 }, @@ -78,9 +49,9 @@ export function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) { ) } -export function getEllipseIndicatorPathTDSnapshot(shape: EllipseShape, boundsCenter: number[]) { +export function getEllipseIndicatorPath(id: string, radius: number[], style: ShapeStyles) { return Utils.getSvgPathFromStroke( - getEllipseStrokePoints(shape, boundsCenter).map((pt) => pt.point.slice(0, 2)), + getEllipseStrokePoints(id, radius, style).map((pt) => pt.point.slice(0, 2)), false ) } diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/RectangleUtil.tsx b/packages/tldraw/src/state/shapes/RectangleUtil/RectangleUtil.tsx index 805727b3c..8f76c74af 100644 --- a/packages/tldraw/src/state/shapes/RectangleUtil/RectangleUtil.tsx +++ b/packages/tldraw/src/state/shapes/RectangleUtil/RectangleUtil.tsx @@ -1,20 +1,25 @@ import * as React from 'react' 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 { GHOSTED_OPACITY } from '~constants' +import { GHOSTED_OPACITY, LABEL_POINT } from '~constants' import { TDShapeUtil } from '../TDShapeUtil' import { defaultStyle, getShapeStyle, getBoundsRectangle, transformRectangle, + getFontStyle, transformSingleRectangle, } from '~state/shapes/shared' +import { TextLabel } from '../shared/TextLabel' +import { getRectangleIndicatorPathTDSnapshot } from './rectangleHelpers' +import { DrawRectangle } from './components/DrawRectangle' +import { DashedRectangle } from './components/DashedRectangle' +import { BindingIndicator } from './components/BindingIndicator' +import { styled } from '~styles' type T = RectangleShape -type E = SVGSVGElement +type E = HTMLDivElement export class RectangleUtil extends TDShapeUtil { type = TDShapeType.Rectangle as const @@ -23,6 +28,8 @@ export class RectangleUtil extends TDShapeUtil { canClone = true + canEdit = true + getShape = (props: Partial): T => { return Utils.deepMerge( { @@ -35,139 +42,72 @@ export class RectangleUtil extends TDShapeUtil { size: [1, 1], rotation: 0, style: defaultStyle, + label: '', + labelPoint: [0.5, 0.5], }, props ) } Component = TDShapeUtil.Component( - ({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => { - const { id, size, style } = shape - + ( + { + shape, + isEditing, + isBinding, + isSelected, + isGhost, + meta, + bounds, + events, + onShapeBlur, + onShapeChange, + }, + ref + ) => { + const { id, size, style, label = '', labelPoint = LABEL_POINT } = shape + const font = getFontStyle(style) const styles = getShapeStyle(style, meta.isDarkMode) - - const { strokeWidth } = styles - - if (style.dash === DashStyle.Draw) { - const pathTDSnapshot = getRectanglePath(shape) - const indicatorPath = getRectangleIndicatorPathTDSnapshot(shape) - - return ( - - {isBinding && ( - - )} - - - onShapeChange?.({ id, label }), + [onShapeChange] + ) + return ( + + + + {isBinding && } + - ) - } - - const sw = 1 + strokeWidth * 1.618 - - const w = Math.max(0, size[0] - sw / 2) - const h = Math.max(0, size[1] - sw / 2) - - const strokes: [number[], number[], number][] = [ - [[sw / 2, sw / 2], [w, sw / 2], w - sw / 2], - [[w, sw / 2], [w, h], h - sw / 2], - [[w, h], [sw / 2, h], w - sw / 2], - [[sw / 2, h], [sw / 2, sw / 2], h - sw / 2], - ] - - const paths = strokes.map(([start, end, length], i) => { - const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( - length, - strokeWidth * 1.618, - shape.style.dash - ) - - return ( - - ) - }) - - return ( - - - {isBinding && ( - - )} - - {style.isFilled && ( - - )} - - {paths} - - - + ) } ) Indicator = TDShapeUtil.Indicator(({ shape }) => { - const { - style, - size: [width, height], - } = shape + const { id, style, size } = shape const styles = getShapeStyle(style, false) const sw = styles.strokeWidth if (style.dash === DashStyle.Draw) { - return + return } return ( @@ -176,8 +116,8 @@ export class RectangleUtil extends TDShapeUtil { y={sw} rx={1} ry={1} - width={Math.max(1, width - sw * 2)} - height={Math.max(1, height - sw * 2)} + width={Math.max(1, size[0] - sw * 2)} + height={Math.max(1, size[1] - sw * 2)} /> ) }) @@ -187,7 +127,7 @@ export class RectangleUtil extends TDShapeUtil { } shouldRender = (prev: T, next: T) => { - return next.size !== prev.size || next.style !== prev.style + return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label } transform = transformRectangle @@ -195,102 +135,4 @@ export class RectangleUtil extends TDShapeUtil { transformSingle = transformSingleRectangle } -/* -------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------- */ - -function getRectangleDrawPoints(shape: RectangleShape) { - const styles = getShapeStyle(shape.style) - - const getRandom = Utils.rng(shape.id) - - const sw = styles.strokeWidth - - // Dimensions - const w = Math.max(0, shape.size[0]) - const h = Math.max(0, shape.size[1]) - - // Random corner offsets - const offsets = Array.from(Array(4)).map(() => { - return [getRandom() * sw * 0.75, getRandom() * sw * 0.75] - }) - - // Corners - const tl = Vec.add([sw / 2, sw / 2], offsets[0]) - const tr = Vec.add([w - sw / 2, sw / 2], offsets[1]) - const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2]) - const bl = Vec.add([sw / 2, h - sw / 2], offsets[3]) - - // Which side to start drawing first - const rm = Math.round(Math.abs(getRandom() * 2 * 4)) - - // Corner radii - const rx = Math.min(w / 2, sw * 2) - const ry = Math.min(h / 2, sw * 2) - - // Number of points per side - const px = Math.max(8, Math.floor(w / 16)) - const py = Math.max(8, Math.floor(h / 16)) - - // Inset each line by the corner radii and let the freehand algo - // interpolate points for the corners. - const lines = Utils.rotateArray( - [ - Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px), - Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py), - Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px), - Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py), - ], - 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]].slice( - 5, - Math.floor((rm % 2 === 0 ? px : py) / -2) + 3 - ) - - return { - points, - } -} - -function getDrawStrokeInfo(shape: RectangleShape) { - const { points } = getRectangleDrawPoints(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 getRectanglePath(shape: RectangleShape) { - const { points, options } = getDrawStrokeInfo(shape) - - const stroke = getStroke(points, options) - - return Utils.getSvgPathFromStroke(stroke) -} - -function getRectangleIndicatorPathTDSnapshot(shape: RectangleShape) { - const { points, options } = getDrawStrokeInfo(shape) - - const strokePoints = getStrokePoints(points, options) - - return Utils.getSvgPathFromStroke( - strokePoints.map((pt) => pt.point.slice(0, 2)), - false - ) -} +const FullWrapper = styled('div', { width: '100%', height: '100%' }) diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/__snapshots__/RectangleUtil.spec.tsx.snap b/packages/tldraw/src/state/shapes/RectangleUtil/__snapshots__/RectangleUtil.spec.tsx.snap index 0dfbb638c..5acf63e8c 100644 --- a/packages/tldraw/src/state/shapes/RectangleUtil/__snapshots__/RectangleUtil.spec.tsx.snap +++ b/packages/tldraw/src/state/shapes/RectangleUtil/__snapshots__/RectangleUtil.spec.tsx.snap @@ -4,6 +4,11 @@ exports[`Rectangle shape Creates a shape: rectangle 1`] = ` Object { "childIndex": 1, "id": "rectangle", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Rectangle", "parentId": "page", "point": Array [ diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/components/BindingIndicator.tsx b/packages/tldraw/src/state/shapes/RectangleUtil/components/BindingIndicator.tsx new file mode 100644 index 000000000..c2abb8032 --- /dev/null +++ b/packages/tldraw/src/state/shapes/RectangleUtil/components/BindingIndicator.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +import { BINDING_DISTANCE } from '~constants' + +interface BindingIndicatorProps { + strokeWidth: number + size: number[] +} +export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) { + return ( + + ) +} diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/components/DashedRectangle.tsx b/packages/tldraw/src/state/shapes/RectangleUtil/components/DashedRectangle.tsx new file mode 100644 index 000000000..d199f30c3 --- /dev/null +++ b/packages/tldraw/src/state/shapes/RectangleUtil/components/DashedRectangle.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { Utils } from '@tldraw/core' +import { BINDING_DISTANCE } from '~constants' +import type { ShapeStyles } from '~types' +import { getShapeStyle } from '~state/shapes/shared' + +interface RectangleSvgProps { + id: string + style: ShapeStyles + isSelected: boolean + size: number[] + isDarkMode: boolean +} + +export const DashedRectangle = React.memo(function DashedRectangle({ + id, + style, + size, + isSelected, + isDarkMode, +}: RectangleSvgProps) { + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + + const sw = 1 + strokeWidth * 1.618 + + const w = Math.max(0, size[0] - sw / 2) + const h = Math.max(0, size[1] - sw / 2) + + const strokes: [number[], number[], number][] = [ + [[sw / 2, sw / 2], [w, sw / 2], w - sw / 2], + [[w, sw / 2], [w, h], h - sw / 2], + [[w, h], [sw / 2, h], w - sw / 2], + [[sw / 2, h], [sw / 2, sw / 2], h - sw / 2], + ] + + const paths = strokes.map(([start, end, length], i) => { + const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps( + length, + strokeWidth * 1.618, + style.dash + ) + + return ( + + ) + }) + + return ( + <> + + {style.isFilled && ( + + )} + + {paths} + + + ) +}) diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/components/DrawRectangle.tsx b/packages/tldraw/src/state/shapes/RectangleUtil/components/DrawRectangle.tsx new file mode 100644 index 000000000..d1c352dc4 --- /dev/null +++ b/packages/tldraw/src/state/shapes/RectangleUtil/components/DrawRectangle.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import { getShapeStyle } from '~state/shapes/shared' +import type { ShapeStyles } from '~types' +import { getRectangleIndicatorPathTDSnapshot, getRectanglePath } from '../rectangleHelpers' + +interface RectangleSvgProps { + id: string + style: ShapeStyles + isSelected: boolean + isDarkMode: boolean + size: number[] +} + +export const DrawRectangle = React.memo(function DrawRectangle({ + id, + style, + size, + isSelected, + isDarkMode, +}: RectangleSvgProps) { + const { isFilled } = style + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + const pathTDSnapshot = getRectanglePath(id, style, size) + const innerPath = getRectangleIndicatorPathTDSnapshot(id, style, size) + + return ( + <> + + {isFilled && } + + + ) +}) diff --git a/packages/tldraw/src/state/shapes/RectangleUtil/rectangleHelpers.ts b/packages/tldraw/src/state/shapes/RectangleUtil/rectangleHelpers.ts new file mode 100644 index 000000000..34b9b723b --- /dev/null +++ b/packages/tldraw/src/state/shapes/RectangleUtil/rectangleHelpers.ts @@ -0,0 +1,98 @@ +import { Utils } from '@tldraw/core' +import Vec from '@tldraw/vec' +import getStroke, { getStrokePoints } from 'perfect-freehand' +import type { ShapeStyles } from '~types' +import { getShapeStyle } from '../shared' + +function getRectangleDrawPoints(id: string, style: ShapeStyles, size: number[]) { + const styles = getShapeStyle(style) + + const getRandom = Utils.rng(id) + + const sw = styles.strokeWidth + + // Dimensions + const w = Math.max(0, size[0]) + const h = Math.max(0, size[1]) + + // Random corner offsets + const offsets = Array.from(Array(4)).map(() => { + return [getRandom() * sw * 0.75, getRandom() * sw * 0.75] + }) + + // Corners + const tl = Vec.add([sw / 2, sw / 2], offsets[0]) + const tr = Vec.add([w - sw / 2, sw / 2], offsets[1]) + const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2]) + const bl = Vec.add([sw / 2, h - sw / 2], offsets[3]) + + // Which side to start drawing first + const rm = Math.round(Math.abs(getRandom() * 2 * 4)) + + // Corner radii + const rx = Math.min(w / 2, sw * 2) + const ry = Math.min(h / 2, sw * 2) + + // Number of points per side + const px = Math.max(8, Math.floor(w / 16)) + const py = Math.max(8, Math.floor(h / 16)) + + // Inset each line by the corner radii and let the freehand algo + // interpolate points for the corners. + const lines = Utils.rotateArray( + [ + Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px), + Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py), + Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px), + Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py), + ], + 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]].slice( + 5, + Math.floor((rm % 2 === 0 ? px : py) / -2) + 3 + ) + + return { + points, + } +} + +function getDrawStrokeInfo(id: string, style: ShapeStyles, size: number[]) { + const { points } = getRectangleDrawPoints(id, style, size) + const { strokeWidth } = getShapeStyle(style) + const options = { + size: strokeWidth, + thinning: 0.65, + streamline: 0.3, + smoothing: 1, + simulatePressure: false, + last: true, + } + return { points, options } +} + +export function getRectanglePath(id: string, style: ShapeStyles, size: number[]) { + const { points, options } = getDrawStrokeInfo(id, style, size) + const stroke = getStroke(points, options) + return Utils.getSvgPathFromStroke(stroke) +} + +export function getRectangleIndicatorPathTDSnapshot( + id: string, + style: ShapeStyles, + size: number[] +) { + const { points, options } = getDrawStrokeInfo(id, style, size) + 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/StickyUtil/StickyUtil.tsx b/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx index c54acc35f..4e86a03ad 100644 --- a/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx +++ b/packages/tldraw/src/state/shapes/StickyUtil/StickyUtil.tsx @@ -65,7 +65,7 @@ export class StickyUtil extends TDShapeUtil { e.stopPropagation() }, []) - const handleTextChange = React.useCallback( + const handleLabelChange = React.useCallback( (e: React.ChangeEvent) => { onShapeChange?.({ id: shape.id, @@ -212,7 +212,7 @@ export class StickyUtil extends TDShapeUtil { ref={rTextArea} onPointerDown={handlePointerDown} value={shape.text} - onChange={handleTextChange} + onChange={handleLabelChange} onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} diff --git a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx index f8f0ed7d8..16414ca6b 100644 --- a/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx +++ b/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx @@ -4,7 +4,7 @@ import { Utils, HTMLContainer, TLBounds } from '@tldraw/core' import { defaultTextStyle, getShapeStyle, getFontStyle } from '../shared/shape-styles' import { TextShape, TDMeta, TDShapeType, TransformInfo, AlignStyle } from '~types' import { TextAreaUtils } from '../shared' -import { BINDING_DISTANCE, GHOSTED_OPACITY } from '~constants' +import { BINDING_DISTANCE, GHOSTED_OPACITY, LETTER_SPACING } from '~constants' import { TDShapeUtil } from '../TDShapeUtil' import { styled } from '~styles' import { Vec } from '@tldraw/vec' @@ -48,11 +48,11 @@ export class TextUtil extends TDShapeUtil { Component = TDShapeUtil.Component( ({ shape, isBinding, isGhost, isEditing, onShapeBlur, onShapeChange, meta, events }, ref) => { - const rInput = React.useRef(null) const { text, style } = shape const styles = getShapeStyle(style, meta.isDarkMode) const font = getFontStyle(shape.style) + const rInput = React.useRef(null) const rIsMounted = React.useRef(false) const handleChange = React.useCallback( @@ -209,7 +209,6 @@ export class TextUtil extends TDShapeUtil { color: styles.stroke, }} name="text" - defaultValue={text} tabIndex={-1} autoComplete="false" autoCapitalize="false" @@ -217,16 +216,17 @@ export class TextUtil extends TDShapeUtil { autoSave="false" autoFocus placeholder="" + spellCheck="true" + wrap="off" + dir="auto" + datatype="wysiwyg" + defaultValue={text} color={styles.stroke} onFocus={handleFocus} onChange={handleChange} onKeyDown={handleKeyDown} onBlur={handleBlur} onPointerDown={handlePointerDown} - spellCheck="true" - wrap="off" - dir="auto" - datatype="wysiwyg" onContextMenu={stopPropagation} /> ) : ( @@ -351,8 +351,6 @@ export class TextUtil extends TDShapeUtil { /* Helpers */ /* -------------------------------------------------- */ -const LETTER_SPACING = -1.5 - // eslint-disable-next-line @typescript-eslint/no-explicit-any let melm: any diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx index f8e1b265b..f5cdbe5f2 100644 --- a/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx +++ b/packages/tldraw/src/state/shapes/TriangleUtil/TriangleUtil.tsx @@ -4,10 +4,10 @@ import { TriangleShape, TDShapeType, TDMeta, TDShape, DashStyle } from '~types' import { TDShapeUtil } from '../TDShapeUtil' import { defaultStyle, - getShapeStyle, getBoundsRectangle, transformRectangle, transformSingleRectangle, + getFontStyle, } from '~state/shapes/shared' import { intersectBoundsPolygon, @@ -15,12 +15,16 @@ import { 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' +import { BINDING_DISTANCE, GHOSTED_OPACITY, LABEL_POINT } from '~constants' +import { getTriangleCentroid, getTrianglePoints } from './triangleHelpers' +import { styled } from '~styles' +import { DrawTriangle } from './components/DrawTriangle' +import { DashedTriangle } from './components/DashedTriangle' +import { TextLabel } from '../shared/TextLabel' +import { TriangleBindingIndicator } from './components/TriangleBindingIndicator' type T = TriangleShape -type E = SVGSVGElement +type E = HTMLDivElement export class TriangleUtil extends TDShapeUtil { type = TDShapeType.Triangle as const @@ -29,6 +33,8 @@ export class TriangleUtil extends TDShapeUtil { canClone = true + canEdit = true + getShape = (props: Partial): T => { return Utils.deepMerge( { @@ -41,104 +47,71 @@ export class TriangleUtil extends TDShapeUtil { size: [1, 1], rotation: 0, style: defaultStyle, + label: '', + labelPoint: [0.5, 0.5], }, 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 { id, label = '', size, style, labelPoint = LABEL_POINT } = shape + const font = getFontStyle(style) + const Component = style.dash === DashStyle.Draw ? DrawTriangle : DashedTriangle + const handleLabelChange = React.useCallback( + (label: string) => onShapeChange?.({ id, label }), + [onShapeChange] + ) + const offsetY = React.useMemo(() => { + const center = Vec.div(size, 2) + const centroid = getTriangleCentroid(size) + return (centroid[1] - center[1]) * 0.72 + }, [size]) + 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 + const { size } = shape + return }) private getPoints(shape: T) { @@ -155,7 +128,7 @@ export class TriangleUtil extends TDShapeUtil { } shouldRender = (prev: T, next: T) => { - return next.size !== prev.size || next.style !== prev.style + return next.size !== prev.size || next.style !== prev.style || next.label !== prev.label } getBounds = (shape: T) => { @@ -164,7 +137,7 @@ export class TriangleUtil extends TDShapeUtil { getExpandedBounds = (shape: T) => { return Utils.getBoundsFromPoints( - getTrianglePoints(shape, this.bindingDistance).map((pt) => Vec.add(pt, shape.point)) + getTrianglePoints(shape.size, this.bindingDistance).map((pt) => Vec.add(pt, shape.point)) ) } @@ -193,9 +166,9 @@ export class TriangleUtil extends TDShapeUtil { if (!Utils.pointInBounds(point, expandedBounds)) return - const points = getTrianglePoints(shape).map((pt) => Vec.add(pt, shape.point)) + const points = getTrianglePoints(shape.size).map((pt) => Vec.add(pt, shape.point)) - const expandedPoints = getTrianglePoints(shape, this.bindingDistance).map((pt) => + const expandedPoints = getTrianglePoints(shape.size, this.bindingDistance).map((pt) => Vec.add(pt, shape.point) ) @@ -216,7 +189,7 @@ export class TriangleUtil extends TDShapeUtil { if (!intersections.length) return // The center of the triangle - const center = Vec.add(getTriangleCentroid(shape), shape.point) + const center = Vec.add(getTriangleCentroid(shape.size), 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] @@ -260,127 +233,4 @@ export class TriangleUtil extends TDShapeUtil { transformSingle = transformSingleRectangle } -/* -------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------- */ - -export function getTrianglePoints(shape: T, offset = 0, rotation = 0) { - const { - size: [w, h], - } = shape - - let points = [ - [w / 2, 0], - [w, h], - [0, h], - ] - - if (offset) points = getOffsetPolygon(points, offset) - if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation)) - - 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 - ) -} +const FullWrapper = styled('div', { width: '100%', height: '100%' }) 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 index 578330bb9..5c3a04bd6 100644 --- a/packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap +++ b/packages/tldraw/src/state/shapes/TriangleUtil/__snapshots__/TriangleUtil.spec.tsx.snap @@ -4,6 +4,11 @@ exports[`Triangle shape Creates a shape: triangle 1`] = ` Object { "childIndex": 1, "id": "triangle", + "label": "", + "labelPoint": Array [ + 0.5, + 0.5, + ], "name": "Triangle", "parentId": "page", "point": Array [ diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/components/DashedTriangle.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/components/DashedTriangle.tsx new file mode 100644 index 000000000..08360f54d --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/components/DashedTriangle.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { Utils } from '@tldraw/core' +import type { ShapeStyles } from '~types' +import { getShapeStyle } from '~state/shapes/shared' +import { getTrianglePoints } from '../triangleHelpers' +import Vec from '@tldraw/vec' + +interface TriangleSvgProps { + id: string + size: number[] + style: ShapeStyles + isSelected: boolean + isDarkMode: boolean +} + +export const DashedTriangle = React.memo(function DashedTriangle({ + id, + size, + style, + isSelected, + isDarkMode, +}: TriangleSvgProps) { + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + const sw = 1 + strokeWidth * 1.618 + const points = getTrianglePoints(size) + 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, + style.dash + ) + + return ( + + ) + }) + + const bgPath = points.join() + + return ( + <> + + {style.isFilled && } + {paths} + + ) +}) diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/components/DrawTriangle.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/components/DrawTriangle.tsx new file mode 100644 index 000000000..c47971215 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/components/DrawTriangle.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { getShapeStyle } from '~state/shapes/shared' +import type { ShapeStyles } from '~types' +import { getTriangleIndicatorPathTDSnapshot, getTrianglePath } from '../triangleHelpers' + +interface TriangleSvgProps { + id: string + size: number[] + style: ShapeStyles + isSelected: boolean + isDarkMode: boolean +} + +export const DrawTriangle = React.memo(function DrawTriangle({ + id, + size, + style, + isSelected, + isDarkMode, +}: TriangleSvgProps) { + const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode) + const pathTDSnapshot = getTrianglePath(id, size, style) + const indicatorPath = getTriangleIndicatorPathTDSnapshot(id, size, style) + return ( + <> + + {style.isFilled && } + + + ) +}) diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/components/TriangleBindingIndicator.tsx b/packages/tldraw/src/state/shapes/TriangleUtil/components/TriangleBindingIndicator.tsx new file mode 100644 index 000000000..fd9b82259 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/components/TriangleBindingIndicator.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' +import { BINDING_DISTANCE } from '~constants' +import { getTrianglePoints } from '../triangleHelpers' + +interface TriangleBindingIndicatorProps { + size: number[] +} + +export function TriangleBindingIndicator({ size }: TriangleBindingIndicatorProps) { + const trianglePoints = getTrianglePoints(size).join() + return ( + + ) +} diff --git a/packages/tldraw/src/state/shapes/TriangleUtil/triangleHelpers.ts b/packages/tldraw/src/state/shapes/TriangleUtil/triangleHelpers.ts new file mode 100644 index 000000000..6b889b860 --- /dev/null +++ b/packages/tldraw/src/state/shapes/TriangleUtil/triangleHelpers.ts @@ -0,0 +1,97 @@ +import { Utils } from '@tldraw/core' +import Vec from '@tldraw/vec' +import getStroke, { getStrokePoints } from 'perfect-freehand' +import type { ShapeStyles } from '~types' +import { getShapeStyle } from '../shared' +import { getOffsetPolygon } from '../shared/PolygonUtils' + +export function getTrianglePoints(size: number[], offset = 0, rotation = 0) { + const [w, h] = size + let points = [ + [w / 2, 0], + [w, h], + [0, h], + ] + if (offset) points = getOffsetPolygon(points, offset) + if (rotation) points = points.map((pt) => Vec.rotWith(pt, [w / 2, h / 2], rotation)) + + return points +} + +export function getTriangleCentroid(size: number[]) { + const [w, h] = size + 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(id: string, size: number[], strokeWidth: number) { + const [w, h] = size + const getRandom = Utils.rng(id) + // Random corner offsets + const offsets = Array.from(Array(3)).map(() => { + return [getRandom() * strokeWidth * 0.75, getRandom() * strokeWidth * 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(id: string, size: number[], style: ShapeStyles) { + const { strokeWidth } = getShapeStyle(style) + const { points } = getTriangleDrawPoints(id, size, strokeWidth) + const options = { + size: strokeWidth, + thinning: 0.65, + streamline: 0.3, + smoothing: 1, + simulatePressure: false, + last: true, + } + return { points, options } +} + +export function getTrianglePath(id: string, size: number[], style: ShapeStyles) { + const { points, options } = getDrawStrokeInfo(id, size, style) + const stroke = getStroke(points, options) + return Utils.getSvgPathFromStroke(stroke) +} + +export function getTriangleIndicatorPathTDSnapshot(id: string, size: number[], style: ShapeStyles) { + const { points, options } = getDrawStrokeInfo(id, size, style) + 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/shared/TextLabel.tsx b/packages/tldraw/src/state/shapes/shared/TextLabel.tsx new file mode 100644 index 000000000..702784af3 --- /dev/null +++ b/packages/tldraw/src/state/shapes/shared/TextLabel.tsx @@ -0,0 +1,276 @@ +import * as React from 'react' +import { stopPropagation } from '~components/stopPropagation' +import { GHOSTED_OPACITY, LABEL_POINT, LETTER_SPACING } from '~constants' +import { TLDR } from '~state/TLDR' +import { styled } from '~styles' +import { TextAreaUtils } from '.' +import { getTextLabelSize } from './getTextSize' + +export interface TextLabelProps { + font: string + text: string + isDarkMode: boolean + onBlur?: () => void + onChange: (text: string) => void + offsetY?: number + offsetX?: number + scale?: number + isEditing?: boolean +} + +export const TextLabel = React.memo(function TextLabel({ + isDarkMode, + font, + text, + offsetX = 0, + offsetY = 0, + scale = 1, + isEditing = false, + onBlur, + onChange, +}: TextLabelProps) { + const rInput = React.useRef(null) + const rIsMounted = React.useRef(false) + const size = getTextLabelSize(text, font) + const color = isDarkMode ? 'white' : 'black' + + const handleChange = React.useCallback( + (e: React.ChangeEvent) => { + onChange(TLDR.normalizeText(e.currentTarget.value)) + }, + [onChange] + ) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + // If this keydown was just the meta key or a shortcut + // that includes holding the meta key like (Command+V) + // then leave the event untouched. We also have to explicitly + // Implement undo/redo for some reason in order to get this working + // in the vscode extension. Without the below code the following doesn't work + // + // - You can't cut/copy/paste when when text-editing/focused + // - You can't undo/redo when when text-editing/focused + // - You can't use Command+A to select all the text, when when text-editing/focused + if (!(e.key === 'Meta' || e.metaKey)) { + e.stopPropagation() + } else if (e.key === 'z' && e.metaKey) { + if (e.shiftKey) { + document.execCommand('redo', false) + } else { + document.execCommand('undo', false) + } + e.stopPropagation() + e.preventDefault() + return + } + + if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + e.currentTarget.blur() + return + } + + if (e.key === 'Tab') { + e.preventDefault() + if (e.shiftKey) { + TextAreaUtils.unindent(e.currentTarget) + } else { + TextAreaUtils.indent(e.currentTarget) + } + + onChange(TLDR.normalizeText(e.currentTarget.value)) + } + }, + [onChange] + ) + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + e.currentTarget.setSelectionRange(0, 0) + onBlur?.() + }, + [onBlur] + ) + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + if (!isEditing) return + if (!rIsMounted.current) return + + if (document.activeElement === e.currentTarget) { + e.currentTarget.select() + } + }, + [isEditing] + ) + + const handlePointerDown = React.useCallback( + (e) => { + if (isEditing) { + e.stopPropagation() + } + }, + [isEditing] + ) + + React.useEffect(() => { + if (isEditing) { + requestAnimationFrame(() => { + rIsMounted.current = true + const elm = rInput.current + if (elm) { + elm.focus() + elm.select() + } + }) + } else { + onBlur?.() + } + }, [isEditing, onBlur]) + + const rInnerWrapper = React.useRef(null) + + React.useLayoutEffect(() => { + const elm = rInnerWrapper.current + if (!elm) return + elm.style.transform = `scale(${scale}, ${scale}) translate(${offsetX}px, ${offsetY}px)` + elm.style.width = size[0] + 'px' + elm.style.height = size[1] + 'px' + }, [size, offsetY, offsetX, scale]) + + return ( + + + {isEditing ? ( +