[fix] geo shape text label placement (#1927)

This PR fixes the text label placement for geo shapes. (It also fixes
the way an ellipse renders when set to dash or dotted).

There's still the slightest offset of the text label's outline when you
begin editing. Maybe we should keep the indicator instead?

### Change Type

- [x] `patch` — Bug fix

### Test Plan

Create a hexagon shape
hit enter to type
indicator is offset, text label is no longer offset

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
Steve Ruiz 2023-09-26 09:05:05 -05:00 committed by GitHub
parent 398bd352ae
commit 9e4dbd1901
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 141 additions and 68 deletions

View file

@ -328,6 +328,9 @@ export function clamp(n: number, min: number, max: number): number;
// @public // @public
export function clampRadians(r: number): number; export function clampRadians(r: number): number;
// @public
export function clockwiseAngleDist(a0: number, a1: number): number;
export { computed } export { computed }
// @public (undocumented) // @public (undocumented)

View file

@ -1029,22 +1029,17 @@ input,
/* ---------------- Geo shape ---------------- */ /* ---------------- Geo shape ---------------- */
.tl-text-label { .tl-text-label {
width: 100%;
height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: var(--color-text); color: var(--color-text);
text-shadow: var(--tl-text-outline); text-shadow: var(--tl-text-outline);
line-height: inherit; line-height: inherit;
position: relative; position: absolute;
inset: 0px;
z-index: 10; z-index: 10;
} }
.tl-text-label[data-isediting='true'] {
outline: calc(var(--tl-scale) * 1.5px) solid var(--color-selected);
}
.tl-text-label[data-isediting='true'] .tl-text-content { .tl-text-label[data-isediting='true'] .tl-text-content {
opacity: 0; opacity: 0;
} }
@ -1168,7 +1163,6 @@ input,
.tl-arrow-label[data-isediting='true'] > .tl-arrow-label__inner { .tl-arrow-label[data-isediting='true'] > .tl-arrow-label__inner {
background-color: var(--color-background); background-color: var(--color-background);
border: calc(var(--tl-scale) * 1.5px) solid var(--color-selected);
} }
.tl-arrow-label__inner { .tl-arrow-label__inner {
@ -1355,7 +1349,6 @@ input,
.tl-frame-label__editing { .tl-frame-label__editing {
color: transparent; color: transparent;
outline: 1.5px solid var(--color-selection-stroke);
white-space: pre; white-space: pre;
width: auto; width: auto;
overflow: visible; overflow: visible;

View file

@ -297,6 +297,7 @@ export {
canonicalizeRotation, canonicalizeRotation,
clamp, clamp,
clampRadians, clampRadians,
clockwiseAngleDist,
degreesToRadians, degreesToRadians,
getArcLength, getArcLength,
getPointOnCircle, getPointOnCircle,

View file

@ -322,6 +322,7 @@ function SelectedIdIndicators() {
'select.idle', 'select.idle',
'select.brushing', 'select.brushing',
'select.scribble_brushing', 'select.scribble_brushing',
'select.editing_shape',
'select.pointing_shape', 'select.pointing_shape',
'select.pointing_selection', 'select.pointing_selection',
'select.pointing_handle' 'select.pointing_handle'

View file

@ -6,6 +6,7 @@ import { nearestMultiple } from '../hooks/useDPRMultiple'
import { useEditor } from '../hooks/useEditor' import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents' import { useEditorComponents } from '../hooks/useEditorComponents'
import { Matrix2d } from '../primitives/Matrix2d' import { Matrix2d } from '../primitives/Matrix2d'
import { toDomPrecision } from '../primitives/utils'
import { OptionalErrorBoundary } from './ErrorBoundary' import { OptionalErrorBoundary } from './ErrorBoundary'
/* /*
@ -170,9 +171,11 @@ const CulledShape = React.memo(
<div <div
className="tl-shape__culled" className="tl-shape__culled"
style={{ style={{
transform: `translate(${bounds.minX}px, ${bounds.minY}px)`, transform: `translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(
width: Math.max(1, bounds.width), bounds.minY
height: Math.max(1, bounds.height), )}px)`,
width: Math.max(1, toDomPrecision(bounds.width)),
height: Math.max(1, toDomPrecision(bounds.height)),
}} }}
/> />
) )

View file

@ -109,6 +109,22 @@ export function canonicalizeRotation(a: number) {
return a return a
} }
/**
* Get the clockwise angle distance between two angles.
*
* @param a0 - The first angle.
* @param a1 - The second angle.
* @public
*/
export function clockwiseAngleDist(a0: number, a1: number): number {
a0 = canonicalizeRotation(a0)
a1 = canonicalizeRotation(a1)
if (a0 > a1) {
a1 += PI2
}
return a1 - a0
}
/** /**
* Get the short angle distance between two angles. * Get the short angle distance between two angles.
* *
@ -270,12 +286,27 @@ export function getPointOnCircle(cx: number, cy: number, r: number, a: number) {
export function getPolygonVertices(width: number, height: number, sides: number) { export function getPolygonVertices(width: number, height: number, sides: number) {
const cx = width / 2 const cx = width / 2
const cy = height / 2 const cy = height / 2
const pointsOnPerimeter = [] const pointsOnPerimeter: Vec2d[] = []
let minX = Infinity
let minY = Infinity
for (let i = 0; i < sides; i++) { for (let i = 0; i < sides; i++) {
const step = PI2 / sides const step = PI2 / sides
const t = -TAU + i * step const t = -TAU + i * step
pointsOnPerimeter.push(new Vec2d(cx + cx * Math.cos(t), cy + cy * Math.sin(t))) const x = cx + cx * Math.cos(t)
const y = cy + cy * Math.sin(t)
if (x < minX) minX = x
if (y < minY) minY = y
pointsOnPerimeter.push(new Vec2d(x, y))
} }
if (minX !== 0 || minY !== 0) {
for (let i = 0; i < pointsOnPerimeter.length; i++) {
const pt = pointsOnPerimeter[i]
pt.x -= minX
pt.y -= minY
}
}
return pointsOnPerimeter return pointsOnPerimeter
} }

View file

@ -34,6 +34,7 @@ import {
getSolidStraightArrowPath, getSolidStraightArrowPath,
getStraightArrowHandlePath, getStraightArrowHandlePath,
toDomPrecision, toDomPrecision,
useIsEditing,
} from '@tldraw/editor' } from '@tldraw/editor'
import React from 'react' import React from 'react'
import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill' import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill'
@ -692,6 +693,22 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
const maskId = (shape.id + '_clip').replace(':', '_') const maskId = (shape.id + '_clip').replace(':', '_')
// eslint-disable-next-line react-hooks/rules-of-hooks
const isEditing = useIsEditing(shape.id)
if (isEditing && labelGeometry) {
return (
<rect
x={toDomPrecision(labelGeometry.x)}
y={toDomPrecision(labelGeometry.y)}
width={labelGeometry.w}
height={labelGeometry.h}
rx={3.5}
ry={3.5}
/>
)
}
return ( return (
<g> <g>
{includeMask && ( {includeMask && (

View file

@ -532,6 +532,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
text={text} text={text}
labelColor={labelColor} labelColor={labelColor}
wrap wrap
bounds={props.geo === 'cloud' ? this.getGeometry(shape).bounds : undefined}
/> />
{shape.props.url && ( {shape.props.url && (
<HyperlinkButton url={shape.props.url} zoomLevel={this.editor.zoomLevel} /> <HyperlinkButton url={shape.props.url} zoomLevel={this.editor.zoomLevel} />
@ -813,10 +814,12 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
override onResize: TLOnResizeHandler<TLGeoShape> = ( override onResize: TLOnResizeHandler<TLGeoShape> = (
shape, shape,
{ initialBounds, handle, newPoint, scaleX, scaleY } { handle, newPoint, scaleX, scaleY, initialShape }
) => { ) => {
let w = initialBounds.width * scaleX // use the w/h from props here instead of the initialBounds here,
let h = initialBounds.height * scaleY // 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 overShrinkX = 0
let overShrinkY = 0 let overShrinkY = 0

View file

@ -3,9 +3,10 @@ import {
TLDefaultSizeStyle, TLDefaultSizeStyle,
Vec2d, Vec2d,
Vec2dModel, Vec2dModel,
clockwiseAngleDist,
getPointOnCircle, getPointOnCircle,
rng, rng,
shortAngleDist, toDomPrecision,
} from '@tldraw/editor' } from '@tldraw/editor'
function getPillCircumference(width: number, height: number) { function getPillCircumference(width: number, height: number) {
@ -200,8 +201,8 @@ export function getCloudArcs(
const radius = Vec2d.Dist(center, leftWigglePoint) const radius = Vec2d.Dist(center, leftWigglePoint)
arcs.push({ arcs.push({
leftPoint: leftWigglePoint, leftPoint: leftWigglePoint.clone(),
rightPoint: rightWigglePoint, rightPoint: rightWigglePoint.clone(),
arcPoint, arcPoint,
center, center,
radius, radius,
@ -265,11 +266,13 @@ export function cloudSvgPath(
size: TLDefaultSizeStyle size: TLDefaultSizeStyle
) { ) {
const arcs = getCloudArcs(width, height, seed, size) const arcs = getCloudArcs(width, height, seed, size)
let path = `M${arcs[0].leftPoint.x},${arcs[0].leftPoint.y}` let path = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}`
// now draw arcs for each circle, starting where it intersects the previous circle, and ending where it intersects the next circle // now draw arcs for each circle, starting where it intersects the previous circle, and ending where it intersects the next circle
for (const { rightPoint, radius } of arcs) { for (const { rightPoint, radius } of arcs) {
path += ` A${radius},${radius} 0 0,1 ${rightPoint.x},${rightPoint.y}` path += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 0,1 ${toDomPrecision(
rightPoint.x
)},${toDomPrecision(rightPoint.y)}`
} }
path += ' Z' path += ' Z'
@ -289,18 +292,22 @@ export function inkyCloudSvgPath(
} }
const mutPoint = (p: Vec2d) => new Vec2d(mut(p.x), mut(p.y)) const mutPoint = (p: Vec2d) => new Vec2d(mut(p.x), mut(p.y))
const arcs = getCloudArcs(width, height, seed, size) const arcs = getCloudArcs(width, height, seed, size)
let pathA = `M${arcs[0].leftPoint.x},${arcs[0].leftPoint.y}` let pathA = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}`
let leftMutPoint = mutPoint(arcs[0].leftPoint) let leftMutPoint = mutPoint(arcs[0].leftPoint)
let pathB = `M${leftMutPoint.x},${leftMutPoint.y}` let pathB = `M${toDomPrecision(leftMutPoint.x)},${toDomPrecision(leftMutPoint.y)}`
for (const { rightPoint, radius, arcPoint } of arcs) { for (const { rightPoint, radius, arcPoint } of arcs) {
pathA += ` A${radius},${radius} 0 0,1 ${rightPoint.x},${rightPoint.y}` pathA += ` A${toDomPrecision(radius)},${toDomPrecision(radius)} 0 0,1 ${toDomPrecision(
rightPoint.x
)},${toDomPrecision(rightPoint.y)}`
const rightMutPoint = mutPoint(rightPoint) const rightMutPoint = mutPoint(rightPoint)
const mutArcPoint = mutPoint(arcPoint) const mutArcPoint = mutPoint(arcPoint)
const mutCenter = getCenterOfCircleGivenThreePoints(leftMutPoint, rightMutPoint, mutArcPoint) const mutCenter = getCenterOfCircleGivenThreePoints(leftMutPoint, rightMutPoint, mutArcPoint)
const mutRadius = Math.abs(Vec2d.Dist(mutCenter, leftMutPoint)) const mutRadius = Math.abs(Vec2d.Dist(mutCenter, leftMutPoint))
pathB += ` A${mutRadius},${mutRadius} 0 0,1 ${rightMutPoint.x},${rightMutPoint.y}` pathB += ` A${toDomPrecision(mutRadius)},${toDomPrecision(mutRadius)} 0 0,1 ${toDomPrecision(
rightMutPoint.x
)},${toDomPrecision(rightMutPoint.y)}`
leftMutPoint = rightMutPoint leftMutPoint = rightMutPoint
} }
@ -319,7 +326,7 @@ export function pointsOnArc(
const startAngle = Vec2d.Angle(center, startPoint) const startAngle = Vec2d.Angle(center, startPoint)
const endAngle = Vec2d.Angle(center, endPoint) const endAngle = Vec2d.Angle(center, endPoint)
const l = shortAngleDist(startAngle, endAngle) const l = clockwiseAngleDist(startAngle, endAngle)
for (let i = 0; i < numPoints; i++) { for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1) const t = i / (numPoints - 1)

View file

@ -28,8 +28,8 @@ export const DashStyleEllipse = React.memo(function DashStyleEllipse({
const theme = useDefaultColorTheme() const theme = useDefaultColorTheme()
const cx = w / 2 const cx = w / 2
const cy = h / 2 const cy = h / 2
const rx = Math.max(0, cx - sw / 2) const rx = Math.max(0, cx)
const ry = Math.max(0, cy - sw / 2) const ry = Math.max(0, cy)
const perimeter = perimeterOfEllipse(rx, ry) const perimeter = perimeterOfEllipse(rx, ry)

View file

@ -1,4 +1,5 @@
import { import {
Box2d,
TLDefaultColorStyle, TLDefaultColorStyle,
TLDefaultFillStyle, TLDefaultFillStyle,
TLDefaultFontStyle, TLDefaultFontStyle,
@ -27,6 +28,7 @@ export const TextLabel = React.memo(function TextLabel<
align, align,
verticalAlign, verticalAlign,
wrap, wrap,
bounds,
}: { }: {
id: T['id'] id: T['id']
type: T['type'] type: T['type']
@ -38,6 +40,7 @@ export const TextLabel = React.memo(function TextLabel<
wrap?: boolean wrap?: boolean
text: string text: string
labelColor: TLDefaultColorStyle labelColor: TLDefaultColorStyle
bounds?: Box2d
}) { }) {
const { const {
rInput, rInput,
@ -71,15 +74,25 @@ export const TextLabel = React.memo(function TextLabel<
style={{ style={{
justifyContent: align === 'middle' || legacyAlign ? 'center' : align, justifyContent: align === 'middle' || legacyAlign ? 'center' : align,
alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign, alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign,
...(bounds
? {
top: bounds.minY,
left: bounds.minX,
width: bounds.width,
height: bounds.height,
position: 'absolute',
}
: {}),
}} }}
> >
{isEmpty && !isInteractive ? null : (
<div <div
className="tl-text-label__inner" className="tl-text-label__inner"
style={{ style={{
fontSize: LABEL_FONT_SIZES[size], fontSize: LABEL_FONT_SIZES[size],
lineHeight: LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px', lineHeight: LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px',
minHeight: isEmpty ? LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 32 : 0, minHeight: TEXT_PROPS.lineHeight + 32,
minWidth: isEmpty ? 33 : 0, minWidth: 0,
color: theme[labelColor].solid, color: theme[labelColor].solid,
}} }}
> >
@ -113,6 +126,7 @@ export const TextLabel = React.memo(function TextLabel<
/> />
)} )}
</div> </div>
)}
</div> </div>
) )
}) })