Tldraw/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx

789 wiersze
18 KiB
TypeScript

/* eslint-disable react-hooks/rules-of-hooks */
import {
BaseBoxShapeUtil,
Editor,
Ellipse2d,
Geometry2d,
Group2d,
HALF_PI,
HTMLContainer,
HandleSnapGeometry,
PI2,
Polygon2d,
Polyline2d,
Rectangle2d,
SVGContainer,
Stadium2d,
SvgExportContext,
TLGeoShape,
TLOnEditEndHandler,
TLOnResizeHandler,
TLShapeUtilCanvasSvgDef,
Vec,
exhaustiveSwitchError,
geoShapeMigrations,
geoShapeProps,
getDefaultColorTheme,
getPolygonVertices,
} from '@tldraw/editor'
import { HyperlinkButton } from '../shared/HyperlinkButton'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import {
FONT_FAMILIES,
LABEL_FONT_SIZES,
LABEL_PADDING,
STROKE_SIZES,
TEXT_PROPS,
} from '../shared/default-shape-constants'
import {
getFillDefForCanvas,
getFillDefForExport,
getFontDefForExport,
} from '../shared/defaultStyleDefs'
import { getRoundedInkyPolygonPath, getRoundedPolygonPoints } from '../shared/polygon-helpers'
import { cloudOutline, cloudSvgPath } from './cloudOutline'
import { getEllipseIndicatorPath } from './components/DrawStyleEllipse'
import { GeoShapeBody } from './components/GeoShapeBody'
import { getOvalIndicatorPath } from './components/SolidStyleOval'
import { getLines } from './getLines'
const MIN_SIZE_WITH_LABEL = 17 * 3
/** @public */
export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
static override type = 'geo' as const
static override props = geoShapeProps
static override migrations = geoShapeMigrations
override canEdit = () => true
override getDefaultProps(): 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: '',
}
}
override getGeometry(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
const strokeWidth = STROKE_SIZES[shape.props.size]
const isFilled = shape.props.fill !== 'none' // || shape.props.text.trim().length > 0
let body: Geometry2d
switch (shape.props.geo) {
case 'cloud': {
body = new Polygon2d({
points: cloudOutline(w, h, shape.id, shape.props.size),
isFilled,
})
break
}
case 'triangle': {
body = new Polygon2d({
points: [new Vec(cx, 0), new Vec(w, h), new Vec(0, h)],
isFilled,
})
break
}
case 'diamond': {
body = new Polygon2d({
points: [new Vec(cx, 0), new Vec(w, cy), new Vec(cx, h), new Vec(0, cy)],
isFilled,
})
break
}
case 'pentagon': {
body = new Polygon2d({
points: getPolygonVertices(w, h, 5),
isFilled,
})
break
}
case 'hexagon': {
body = new Polygon2d({
points: getPolygonVertices(w, h, 6),
isFilled,
})
break
}
case 'octagon': {
body = new Polygon2d({
points: getPolygonVertices(w, h, 8),
isFilled,
})
break
}
case 'ellipse': {
body = new Ellipse2d({
width: w,
height: h,
isFilled,
})
break
}
case 'oval': {
body = new Stadium2d({
width: w,
height: h,
isFilled,
})
break
}
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(-HALF_PI + rightMostIndex * step) * w) / 2
const minX = (Math.cos(-HALF_PI + leftMostIndex * step) * w) / 2
const minY = (Math.sin(-HALF_PI + topMostIndex * step) * h) / 2
const maxY = (Math.sin(-HALF_PI + 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
body = new Polygon2d({
points: Array.from(Array(sides * 2)).map((_, i) => {
const theta = -HALF_PI + i * step
return new Vec(
cx + (i % 2 ? ix : ox) * Math.cos(theta),
cy + (i % 2 ? iy : oy) * Math.sin(theta)
)
}),
isFilled,
})
break
}
case 'rhombus': {
const offset = Math.min(w * 0.38, h * 0.38)
body = new Polygon2d({
points: [new Vec(offset, 0), new Vec(w, 0), new Vec(w - offset, h), new Vec(0, h)],
isFilled,
})
break
}
case 'rhombus-2': {
const offset = Math.min(w * 0.38, h * 0.38)
body = new Polygon2d({
points: [new Vec(0, 0), new Vec(w - offset, 0), new Vec(w, h), new Vec(offset, h)],
isFilled,
})
break
}
case 'trapezoid': {
const offset = Math.min(w * 0.38, h * 0.38)
body = new Polygon2d({
points: [new Vec(offset, 0), new Vec(w - offset, 0), new Vec(w, h), new Vec(0, h)],
isFilled,
})
break
}
case 'arrow-right': {
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
body = new Polygon2d({
points: [
new Vec(0, oy),
new Vec(w - ox, oy),
new Vec(w - ox, 0),
new Vec(w, h / 2),
new Vec(w - ox, h),
new Vec(w - ox, h - oy),
new Vec(0, h - oy),
],
isFilled,
})
break
}
case 'arrow-left': {
const ox = Math.min(w, h) * 0.38
const oy = h * 0.16
body = new Polygon2d({
points: [
new Vec(ox, 0),
new Vec(ox, oy),
new Vec(w, oy),
new Vec(w, h - oy),
new Vec(ox, h - oy),
new Vec(ox, h),
new Vec(0, h / 2),
],
isFilled,
})
break
}
case 'arrow-up': {
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
body = new Polygon2d({
points: [
new Vec(w / 2, 0),
new Vec(w, oy),
new Vec(w - ox, oy),
new Vec(w - ox, h),
new Vec(ox, h),
new Vec(ox, oy),
new Vec(0, oy),
],
isFilled,
})
break
}
case 'arrow-down': {
const ox = w * 0.16
const oy = Math.min(w, h) * 0.38
body = new Polygon2d({
points: [
new Vec(ox, 0),
new Vec(w - ox, 0),
new Vec(w - ox, h - oy),
new Vec(w, h - oy),
new Vec(w / 2, h),
new Vec(0, h - oy),
new Vec(ox, h - oy),
],
isFilled,
})
break
}
case 'check-box':
case 'x-box':
case 'rectangle': {
body = new Rectangle2d({
width: w,
height: h,
isFilled,
})
break
}
}
const labelSize = getLabelSize(this.editor, shape)
const minWidth = Math.min(100, w / 2)
const labelWidth = Math.min(w, Math.max(labelSize.w, Math.min(minWidth, Math.max(1, w - 8))))
const minHeight = Math.min(
LABEL_FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight + LABEL_PADDING * 2,
h / 2
)
const labelHeight = Math.min(h, Math.max(labelSize.h, Math.min(minHeight, Math.max(1, w - 8)))) // not sure if bug
const lines = getLines(shape.props, strokeWidth)
const edges = lines ? lines.map((line) => new Polyline2d({ points: line })) : []
// todo: use centroid for label position
return new Group2d({
children: [
body,
new Rectangle2d({
x:
shape.props.align === 'start'
? 0
: shape.props.align === 'end'
? w - labelWidth
: (w - labelWidth) / 2,
y:
shape.props.verticalAlign === 'start'
? 0
: shape.props.verticalAlign === 'end'
? h - labelHeight
: (h - labelHeight) / 2,
width: labelWidth,
height: labelHeight,
isFilled: true,
isLabel: true,
}),
...edges,
],
})
}
override getHandleSnapGeometry(shape: TLGeoShape): HandleSnapGeometry {
const geometry = this.getGeometry(shape)
// we only want to snap handles to the outline of the shape - not to its label etc.
const outline = geometry.children[0]
switch (shape.props.geo) {
case 'arrow-down':
case 'arrow-left':
case 'arrow-right':
case 'arrow-up':
case 'check-box':
case 'diamond':
case 'hexagon':
case 'octagon':
case 'pentagon':
case 'rectangle':
case 'rhombus':
case 'rhombus-2':
case 'star':
case 'trapezoid':
case 'triangle':
case 'x-box':
// poly-line type shapes hand snap points for each vertex & the center
return { outline: outline, points: [...outline.getVertices(), geometry.bounds.center] }
case 'cloud':
case 'ellipse':
case 'oval':
// blobby shapes only have a snap point in their center
return { outline: outline, points: [geometry.bounds.center] }
default:
exhaustiveSwitchError(shape.props.geo)
}
}
override onEditEnd: TLOnEditEndHandler<TLGeoShape> = (shape) => {
const {
id,
type,
props: { text },
} = shape
if (text.trimEnd() !== shape.props.text) {
this.editor.updateShapes([
{
id,
type,
props: {
text: text.trimEnd(),
},
},
])
}
}
component(shape: TLGeoShape) {
const { id, type, props } = shape
const { fill, font, align, verticalAlign, size, text } = props
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
const isEditingAnything = this.editor.getEditingShapeId() !== null
const showHtmlContainer = isEditingAnything || shape.props.text
return (
<>
<SVGContainer id={id}>
<GeoShapeBody shape={shape} />
</SVGContainer>
{showHtmlContainer && (
<HTMLContainer
style={{
overflow: 'hidden',
width: shape.props.w,
height: shape.props.h + props.growY,
}}
>
<TextLabel
id={id}
type={type}
font={font}
fontSize={LABEL_FONT_SIZES[size]}
lineHeight={TEXT_PROPS.lineHeight}
fill={fill}
align={align}
verticalAlign={verticalAlign}
text={text}
isSelected={isSelected}
labelColor={theme[props.labelColor].solid}
wrap
/>
</HTMLContainer>
)}
{shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.getZoomLevel()} />
)}
</>
)
}
indicator(shape: TLGeoShape) {
const { id, props } = shape
const { w, size } = props
const h = props.h + props.growY
const strokeWidth = STROKE_SIZES[size]
switch (props.geo) {
case 'ellipse': {
if (props.dash === 'draw') {
return <path d={getEllipseIndicatorPath(id, w, h, strokeWidth)} />
}
return <ellipse cx={w / 2} cy={h / 2} rx={w / 2} ry={h / 2} />
}
case 'oval': {
return <path d={getOvalIndicatorPath(w, h)} />
}
case 'cloud': {
return <path d={cloudSvgPath(w, h, id, size)} />
}
default: {
const geometry = this.editor.getShapeGeometry(shape)
const outline =
geometry instanceof Group2d ? geometry.children[0].vertices : geometry.vertices
let path: string
if (props.dash === 'draw') {
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 <path d={path} />
}
}
}
override toSvg(shape: TLGeoShape, ctx: SvgExportContext) {
const { props } = shape
ctx.addExportDef(getFillDefForExport(shape.props.fill))
let textEl
if (props.text) {
ctx.addExportDef(getFontDefForExport(shape.props.font))
const theme = getDefaultColorTheme(ctx)
const bounds = this.editor.getShapeGeometry(shape).bounds
textEl = (
<SvgTextLabel
fontSize={LABEL_FONT_SIZES[props.size]}
font={props.font}
align={props.align}
verticalAlign={props.verticalAlign}
text={props.text}
labelColor={theme[props.labelColor].solid}
bounds={bounds}
/>
)
}
return (
<>
<GeoShapeBody shape={shape} />
{textEl}
</>
)
}
override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] {
return [getFillDefForCanvas()]
}
override onResize: TLOnResizeHandler<TLGeoShape> = (
shape,
{ handle, newPoint, scaleX, scaleY, initialShape }
) => {
// use the w/h from props here instead of the initialBounds here,
// since cloud shapes calculated bounds can differ from the props w/h.
let w = initialShape.props.w * scaleX
let h = (initialShape.props.h + initialShape.props.growY) * 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 Vec(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,
},
}
}
override 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,
},
}
}
}
override onBeforeUpdate = (prev: TLGeoShape, next: TLGeoShape) => {
const prevText = prev.props.text
const nextText = next.props.text
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,
},
}
}
}
override 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
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],
maxWidth: 100,
})
// 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],
minWidth: minSize.w,
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)
),
})
return {
w: size.w + LABEL_PADDING * 2,
h: size.h + LABEL_PADDING * 2,
}
}