/* eslint-disable react-hooks/rules-of-hooks */ import { Box2d, getPolygonVertices, getRoundedInkyPolygonPath, getRoundedPolygonPoints, linesIntersect, PI, PI2, pointInPolygon, TAU, Vec2d, VecLike, } from '@tldraw/primitives' import { TLDashType, TLGeoShape } from '@tldraw/tlschema' import { SVGContainer } from '../../../components/SVGContainer' import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants' import { Editor } from '../../Editor' import { BaseBoxShapeUtil } from '../BaseBoxShapeUtil' import { TLOnEditEndHandler, TLOnResizeHandler } from '../ShapeUtil' import { getTextLabelSvgElement } from '../shared/getTextLabelSvgElement' import { HyperlinkButton } from '../shared/HyperlinkButton' import { TextLabel } from '../shared/TextLabel' import { TLExportColors } from '../shared/TLExportColors' import { useForceSolid } from '../shared/useForceSolid' import { DashStyleEllipse, DashStyleEllipseSvg } from './components/DashStyleEllipse' import { DashStyleOval, DashStyleOvalSvg } from './components/DashStyleOval' import { DashStylePolygon, DashStylePolygonSvg } from './components/DashStylePolygon' import { DrawStyleEllipseSvg, getEllipseIndicatorPath } from './components/DrawStyleEllipse' import { DrawStylePolygon, DrawStylePolygonSvg } from './components/DrawStylePolygon' import { SolidStyleEllipse, SolidStyleEllipseSvg } from './components/SolidStyleEllipse' import { getOvalIndicatorPath, SolidStyleOval, SolidStyleOvalSvg, } from './components/SolidStyleOval' import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon' const LABEL_PADDING = 16 const MIN_SIZE_WITH_LABEL = 17 * 3 /** @public */ export class GeoShapeUtil extends BaseBoxShapeUtil { static override type = 'geo' canEdit = () => true override defaultProps(): TLGeoShape['props'] { return { w: 100, h: 100, geo: 'rectangle', color: 'black', labelColor: 'black', fill: 'none', dash: 'draw', size: 'm', font: 'draw', text: '', align: 'middle', verticalAlign: 'middle', growY: 0, url: '', } } hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean { const outline = this.outline(shape) // Check the outline for (let i = 0; i < outline.length; i++) { const C = outline[i] const D = outline[(i + 1) % outline.length] if (linesIntersect(A, B, C, D)) return true } // Also check lines, if any const lines = getLines(shape.props, 0) if (lines !== undefined) { for (const [C, D] of lines) { if (linesIntersect(A, B, C, D)) return true } } return false } hitTestPoint(shape: TLGeoShape, point: VecLike): boolean { const outline = this.outline(shape) if (shape.props.fill === 'none') { const zoomLevel = this.editor.zoomLevel const offsetDist = this.editor.getStrokeWidth(shape.props.size) / zoomLevel // Check the outline for (let i = 0; i < outline.length; i++) { const C = outline[i] const D = outline[(i + 1) % outline.length] if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true } // Also check lines, if any const lines = getLines(shape.props, 1) if (lines !== undefined) { for (const [C, D] of lines) { if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true } } return false } return pointInPolygon(point, outline) } getBounds(shape: TLGeoShape) { return new Box2d(0, 0, shape.props.w, shape.props.h + shape.props.growY) } getCenter(shape: TLGeoShape) { return new Vec2d(shape.props.w / 2, (shape.props.h + shape.props.growY) / 2) } getOutline(shape: TLGeoShape) { const w = Math.max(1, shape.props.w) const h = Math.max(1, shape.props.h + shape.props.growY) const cx = w / 2 const cy = h / 2 switch (shape.props.geo) { case 'triangle': { return [new Vec2d(cx, 0), new Vec2d(w, h), new Vec2d(0, h)] } case 'diamond': { return [new Vec2d(cx, 0), new Vec2d(w, cy), new Vec2d(cx, h), new Vec2d(0, cy)] } case 'pentagon': { return getPolygonVertices(w, h, 5) } case 'hexagon': { return getPolygonVertices(w, h, 6) } case 'octagon': { return getPolygonVertices(w, h, 8) } case 'ellipse': { // Perimeter of the ellipse const q = Math.pow(cx - cy, 2) / Math.pow(cx + cy, 2) const p = PI * (cx + cy) * (1 + (3 * q) / (10 + Math.sqrt(4 - 3 * q))) // Number of points let len = Math.max(4, Math.ceil(p / 10)) // Round length to nearest multiple of 4 // In some cases, this stops the outline overlapping with the indicator // (it doesn't prevent all cases though, eg: when the shape is on the edge of a group) len = Math.ceil(len / 4) * 4 // Size of step const step = PI2 / len const a = Math.cos(step) const b = Math.sin(step) let sin = 0 let cos = 1 let ts = 0 let tc = 1 const points: Vec2d[] = Array(len) for (let i = 0; i < len; i++) { points[i] = new Vec2d(cx + cx * cos, cy + cy * sin) ts = b * cos + a * sin tc = a * cos - b * sin sin = ts cos = tc } return points } case 'oval': { const len = 10 const points: Vec2d[] = Array(len * 2) if (h > w) { for (let i = 0; i < len; i++) { const t1 = -PI + (PI * i) / (len - 2) const t2 = (PI * i) / (len - 2) points[i] = new Vec2d(cx + cx * Math.cos(t1), cx + cx * Math.sin(t1)) points[i + len] = new Vec2d(cx + cx * Math.cos(t2), h - cx + cx * Math.sin(t2)) } } else { for (let i = 0; i < len; i++) { const t1 = -TAU + (PI * i) / (len - 2) const t2 = TAU + (PI * -i) / (len - 2) points[i] = new Vec2d(w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1)) points[i + len] = new Vec2d(cy - cy * Math.cos(t2), h - cy + cy * Math.sin(t2)) } } return points } case 'star': { // Most of this code is to offset the center, a 5 point star // will need to be moved downward because from its center [0,0] // it will have a bigger minY than maxY. This is because it'll // have 2 points at the bottom. const sides = 5 const step = PI2 / sides / 2 const rightMostIndex = Math.floor(sides / 4) * 2 const leftMostIndex = sides * 2 - rightMostIndex const topMostIndex = 0 const bottomMostIndex = Math.floor(sides / 2) * 2 const maxX = (Math.cos(-TAU + rightMostIndex * step) * w) / 2 const minX = (Math.cos(-TAU + leftMostIndex * step) * w) / 2 const minY = (Math.sin(-TAU + topMostIndex * step) * h) / 2 const maxY = (Math.sin(-TAU + bottomMostIndex * step) * h) / 2 const diffX = w - Math.abs(maxX - minX) const diffY = h - Math.abs(maxY - minY) const offsetX = w / 2 + minX - (w / 2 - maxX) const offsetY = h / 2 + minY - (h / 2 - maxY) const ratio = 1 const cx = (w - offsetX) / 2 const cy = (h - offsetY) / 2 const ox = (w + diffX) / 2 const oy = (h + diffY) / 2 const ix = (ox * ratio) / 2 const iy = (oy * ratio) / 2 return Array.from(Array(sides * 2)).map((_, i) => { const theta = -TAU + i * step return new Vec2d( cx + (i % 2 ? ix : ox) * Math.cos(theta), cy + (i % 2 ? iy : oy) * Math.sin(theta) ) }) } case 'rhombus': { const offset = Math.min(w * 0.38, h * 0.38) return [new Vec2d(offset, 0), new Vec2d(w, 0), new Vec2d(w - offset, h), new Vec2d(0, h)] } case 'rhombus-2': { const offset = Math.min(w * 0.38, h * 0.38) return [new Vec2d(0, 0), new Vec2d(w - offset, 0), new Vec2d(w, h), new Vec2d(offset, h)] } case 'trapezoid': { const offset = Math.min(w * 0.38, h * 0.38) return [new Vec2d(offset, 0), new Vec2d(w - offset, 0), new Vec2d(w, h), new Vec2d(0, h)] } case 'arrow-right': { const ox = Math.min(w, h) * 0.38 const oy = h * 0.16 return [ new Vec2d(0, oy), new Vec2d(w - ox, oy), new Vec2d(w - ox, 0), new Vec2d(w, h / 2), new Vec2d(w - ox, h), new Vec2d(w - ox, h - oy), new Vec2d(0, h - oy), ] } case 'arrow-left': { const ox = Math.min(w, h) * 0.38 const oy = h * 0.16 return [ new Vec2d(ox, 0), new Vec2d(ox, oy), new Vec2d(w, oy), new Vec2d(w, h - oy), new Vec2d(ox, h - oy), new Vec2d(ox, h), new Vec2d(0, h / 2), ] } case 'arrow-up': { const ox = w * 0.16 const oy = Math.min(w, h) * 0.38 return [ new Vec2d(w / 2, 0), new Vec2d(w, oy), new Vec2d(w - ox, oy), new Vec2d(w - ox, h), new Vec2d(ox, h), new Vec2d(ox, oy), new Vec2d(0, oy), ] } case 'arrow-down': { const ox = w * 0.16 const oy = Math.min(w, h) * 0.38 return [ new Vec2d(ox, 0), new Vec2d(w - ox, 0), new Vec2d(w - ox, h - oy), new Vec2d(w, h - oy), new Vec2d(w / 2, h), new Vec2d(0, h - oy), new Vec2d(ox, h - oy), ] } case 'check-box': case 'x-box': case 'rectangle': { return [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)] } } } onEditEnd: TLOnEditEndHandler = (shape) => { const { id, type, props: { text }, } = shape if (text.trimEnd() !== shape.props.text) { this.editor.updateShapes([ { id, type, props: { text: text.trimEnd(), }, }, ]) } } render(shape: TLGeoShape) { const { id, type, props } = shape const forceSolid = useForceSolid() const strokeWidth = this.editor.getStrokeWidth(props.size) const { w, color, labelColor, fill, dash, growY, font, align, verticalAlign, size, text } = props const getShape = () => { const h = props.h + growY switch (props.geo) { case 'ellipse': { if (dash === 'solid' || (dash === 'draw' && forceSolid)) { return ( ) } else if (dash === 'dashed' || dash === 'dotted') { return ( ) } else if (dash === 'draw') { return ( ) } break } case 'oval': { if (dash === 'solid' || (dash === 'draw' && forceSolid)) { return ( ) } else if (dash === 'dashed' || dash === 'dotted') { return ( ) } else if (dash === 'draw') { return ( ) } break } default: { const outline = this.outline(shape) const lines = getLines(shape.props, strokeWidth) if (dash === 'solid' || (dash === 'draw' && forceSolid)) { return ( ) } else if (dash === 'dashed' || dash === 'dotted') { return ( ) } else if (dash === 'draw') { return ( ) } } } } return ( <> {getShape()} {'url' in shape.props && shape.props.url && ( )} ) } indicator(shape: TLGeoShape) { const { id, props } = shape const { w, h, growY, size } = props const forceSolid = useForceSolid() const strokeWidth = this.editor.getStrokeWidth(size) switch (props.geo) { case 'ellipse': { if (props.dash === 'draw' && !forceSolid) { return } return } case 'oval': { return } default: { const outline = this.outline(shape) let path: string if (props.dash === 'draw' && !forceSolid) { const polygonPoints = getRoundedPolygonPoints(id, outline, 0, strokeWidth * 2, 1) path = getRoundedInkyPolygonPath(polygonPoints) } else { path = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z' } const lines = getLines(shape.props, strokeWidth) if (lines) { for (const [A, B] of lines) { path += `M${A.x},${A.y}L${B.x},${B.y}` } } return } } } toSvg(shape: TLGeoShape, font: string, colors: TLExportColors) { const { id, props } = shape const strokeWidth = this.editor.getStrokeWidth(props.size) let svgElm: SVGElement switch (props.geo) { case 'ellipse': { switch (props.dash) { case 'draw': svgElm = DrawStyleEllipseSvg({ id, w: props.w, h: props.h, color: props.color, fill: props.fill, strokeWidth, colors, }) break case 'solid': svgElm = SolidStyleEllipseSvg({ strokeWidth, w: props.w, h: props.h, color: props.color, fill: props.fill, colors, }) break default: svgElm = DashStyleEllipseSvg({ id, strokeWidth, w: props.w, h: props.h, dash: props.dash, color: props.color, fill: props.fill, colors, }) break } break } case 'oval': { switch (props.dash) { case 'draw': svgElm = DashStyleOvalSvg({ id, strokeWidth, w: props.w, h: props.h, dash: props.dash, color: props.color, fill: props.fill, colors, }) break case 'solid': svgElm = SolidStyleOvalSvg({ strokeWidth, w: props.w, h: props.h, color: props.color, fill: props.fill, colors, }) break default: svgElm = DashStyleOvalSvg({ id, strokeWidth, w: props.w, h: props.h, dash: props.dash, color: props.color, fill: props.fill, colors, }) } break } default: { const outline = this.outline(shape) const lines = getLines(shape.props, strokeWidth) switch (props.dash) { case 'draw': svgElm = DrawStylePolygonSvg({ id, fill: props.fill, color: props.color, strokeWidth, outline, lines, colors, }) break case 'solid': svgElm = SolidStylePolygonSvg({ fill: props.fill, color: props.color, strokeWidth, outline, lines, colors, }) break default: svgElm = DashStylePolygonSvg({ dash: props.dash, fill: props.fill, color: props.color, strokeWidth, outline, lines, colors, }) break } break } } if (props.text) { const bounds = this.bounds(shape) const rootTextElm = getTextLabelSvgElement({ editor: this.editor, shape, font, bounds, }) const textElm = rootTextElm.cloneNode(true) as SVGTextElement textElm.setAttribute('fill', colors.fill[shape.props.labelColor]) textElm.setAttribute('stroke', 'none') const textBgEl = rootTextElm.cloneNode(true) as SVGTextElement textBgEl.setAttribute('stroke-width', '2') textBgEl.setAttribute('fill', colors.background) textBgEl.setAttribute('stroke', colors.background) const groupEl = document.createElementNS('http://www.w3.org/2000/svg', 'g') groupEl.append(textBgEl) groupEl.append(textElm) if (svgElm.nodeName === 'g') { svgElm.appendChild(groupEl) return svgElm } else { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') g.appendChild(svgElm) g.appendChild(groupEl) return g } } return svgElm } onResize: TLOnResizeHandler = ( shape, { initialBounds, handle, newPoint, scaleX, scaleY } ) => { let w = initialBounds.width * scaleX let h = initialBounds.height * scaleY let overShrinkX = 0 let overShrinkY = 0 if (shape.props.text.trim()) { let newW = Math.max(Math.abs(w), MIN_SIZE_WITH_LABEL) let newH = Math.max(Math.abs(h), MIN_SIZE_WITH_LABEL) if (newW < MIN_SIZE_WITH_LABEL && newH === MIN_SIZE_WITH_LABEL) { newW = MIN_SIZE_WITH_LABEL } if (newW === MIN_SIZE_WITH_LABEL && newH < MIN_SIZE_WITH_LABEL) { newH = MIN_SIZE_WITH_LABEL } const labelSize = getLabelSize(this.editor, { ...shape, props: { ...shape.props, w: newW, h: newH, }, }) const nextW = Math.max(Math.abs(w), labelSize.w) * Math.sign(w) const nextH = Math.max(Math.abs(h), labelSize.h) * Math.sign(h) overShrinkX = Math.abs(nextW) - Math.abs(w) overShrinkY = Math.abs(nextH) - Math.abs(h) w = nextW h = nextH } const offset = new Vec2d(0, 0) // x offsets if (scaleX < 0) { offset.x += w } if (handle === 'left' || handle === 'top_left' || handle === 'bottom_left') { offset.x += scaleX < 0 ? overShrinkX : -overShrinkX } // y offsets if (scaleY < 0) { offset.y += h } if (handle === 'top' || handle === 'top_left' || handle === 'top_right') { offset.y += scaleY < 0 ? overShrinkY : -overShrinkY } const { x, y } = offset.rot(shape.rotation).add(newPoint) return { x, y, props: { w: Math.max(Math.abs(w), 1), h: Math.max(Math.abs(h), 1), growY: 0, }, } } onBeforeCreate = (shape: TLGeoShape) => { if (!shape.props.text) { if (shape.props.growY) { // No text / some growY, set growY to 0 return { ...shape, props: { ...shape.props, growY: 0, }, } } else { // No text / no growY, nothing to change return } } const prevHeight = shape.props.h const nextHeight = getLabelSize(this.editor, shape).h let growY: number | null = null if (nextHeight > prevHeight) { growY = nextHeight - prevHeight } else { if (shape.props.growY) { growY = 0 } } if (growY !== null) { return { ...shape, props: { ...shape.props, growY, }, } } } onBeforeUpdate = (prev: TLGeoShape, next: TLGeoShape) => { const prevText = prev.props.text.trimEnd() const nextText = next.props.text.trimEnd() if ( prevText === nextText && prev.props.font === next.props.font && prev.props.size === next.props.size ) { return } if (prevText && !nextText) { return { ...next, props: { ...next.props, growY: 0, }, } } const prevWidth = prev.props.w const prevHeight = prev.props.h const nextSize = getLabelSize(this.editor, next) const nextWidth = nextSize.w const nextHeight = nextSize.h // When entering the first character in a label (not pasting in multiple characters...) if (!prevText && nextText && nextText.length === 1) { let w = Math.max(prevWidth, nextWidth) let h = Math.max(prevHeight, nextHeight) // If both the width and height were less than the minimum size, make the shape square if (prev.props.w < MIN_SIZE_WITH_LABEL && prev.props.h < MIN_SIZE_WITH_LABEL) { w = Math.max(w, MIN_SIZE_WITH_LABEL) h = Math.max(h, MIN_SIZE_WITH_LABEL) w = Math.max(w, h) h = Math.max(w, h) } // Don't set a growY—at least, not until we've implemented a growX property return { ...next, props: { ...next.props, w, h, growY: 0, }, } } let growY: number | null = null if (nextHeight > prevHeight) { growY = nextHeight - prevHeight } else { if (prev.props.growY) { growY = 0 } } if (growY !== null) { return { ...next, props: { ...next.props, growY, w: Math.max(next.props.w, nextWidth), }, } } if (nextWidth > prev.props.w) { return { ...next, props: { ...next.props, w: nextWidth, }, } } } onDoubleClick = (shape: TLGeoShape) => { // Little easter egg: double-clicking a rectangle / checkbox while // holding alt will toggle between check-box and rectangle if (this.editor.inputs.altKey) { switch (shape.props.geo) { case 'rectangle': { return { ...shape, props: { geo: 'check-box' as const, }, } } case 'check-box': { return { ...shape, props: { geo: 'rectangle' as const, }, } } } } return } } function getLabelSize(editor: Editor, shape: TLGeoShape) { const text = shape.props.text.trimEnd() if (!text) { return { w: 0, h: 0 } } const minSize = editor.textMeasure.measureText('w', { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[shape.props.font], fontSize: LABEL_FONT_SIZES[shape.props.size], width: 'fit-content', maxWidth: '100px', }) // TODO: Can I get these from somewhere? const sizes = { s: 2, m: 3.5, l: 5, xl: 10, } const size = editor.textMeasure.measureText(text, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[shape.props.font], fontSize: LABEL_FONT_SIZES[shape.props.size], width: 'fit-content', minWidth: minSize.w + 'px', maxWidth: Math.max( // Guard because a DOM nodes can't be less 0 0, // A 'w' width that we're setting as the min-width Math.ceil(minSize.w + sizes[shape.props.size]), // The actual text size Math.ceil(shape.props.w - LABEL_PADDING * 2) ) + 'px', }) return { w: size.w + LABEL_PADDING * 2, h: size.h + LABEL_PADDING * 2, } } function getLines(props: TLGeoShape['props'], sw: number) { switch (props.geo) { case 'x-box': { return getXBoxLines(props.w, props.h, sw, props.dash) } case 'check-box': { return getCheckBoxLines(props.w, props.h) } default: { return undefined } } } function getXBoxLines(w: number, h: number, sw: number, dash: TLDashType) { const inset = dash === 'draw' ? 0.62 : 0 if (dash === 'dashed') { return [ [new Vec2d(0, 0), new Vec2d(w / 2, h / 2)], [new Vec2d(w, h), new Vec2d(w / 2, h / 2)], [new Vec2d(0, h), new Vec2d(w / 2, h / 2)], [new Vec2d(w, 0), new Vec2d(w / 2, h / 2)], ] } return [ [new Vec2d(sw * inset, sw * inset), new Vec2d(w - sw * inset, h - sw * inset)], [new Vec2d(sw * inset, h - sw * inset), new Vec2d(w - sw * inset, sw * inset)], ] } function getCheckBoxLines(w: number, h: number) { const size = Math.min(w, h) * 0.82 const ox = (w - size) / 2 const oy = (h - size) / 2 return [ [new Vec2d(ox + size * 0.25, oy + size * 0.52), new Vec2d(ox + size * 0.45, oy + size * 0.82)], [new Vec2d(ox + size * 0.45, oy + size * 0.82), new Vec2d(ox + size * 0.82, oy + size * 0.22)], ] }