[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
export function clampRadians(r: number): number;
// @public
export function clockwiseAngleDist(a0: number, a1: number): number;
export { computed }
// @public (undocumented)

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { nearestMultiple } from '../hooks/useDPRMultiple'
import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents'
import { Matrix2d } from '../primitives/Matrix2d'
import { toDomPrecision } from '../primitives/utils'
import { OptionalErrorBoundary } from './ErrorBoundary'
/*
@ -170,9 +171,11 @@ const CulledShape = React.memo(
<div
className="tl-shape__culled"
style={{
transform: `translate(${bounds.minX}px, ${bounds.minY}px)`,
width: Math.max(1, bounds.width),
height: Math.max(1, bounds.height),
transform: `translate(${toDomPrecision(bounds.minX)}px, ${toDomPrecision(
bounds.minY
)}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
}
/**
* 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.
*
@ -270,12 +286,27 @@ export function getPointOnCircle(cx: number, cy: number, r: number, a: number) {
export function getPolygonVertices(width: number, height: number, sides: number) {
const cx = width / 2
const cy = height / 2
const pointsOnPerimeter = []
const pointsOnPerimeter: Vec2d[] = []
let minX = Infinity
let minY = Infinity
for (let i = 0; i < sides; i++) {
const step = PI2 / sides
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
}

View file

@ -34,6 +34,7 @@ import {
getSolidStraightArrowPath,
getStraightArrowHandlePath,
toDomPrecision,
useIsEditing,
} from '@tldraw/editor'
import React from 'react'
import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill'
@ -692,6 +693,22 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
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 (
<g>
{includeMask && (

View file

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

View file

@ -3,9 +3,10 @@ import {
TLDefaultSizeStyle,
Vec2d,
Vec2dModel,
clockwiseAngleDist,
getPointOnCircle,
rng,
shortAngleDist,
toDomPrecision,
} from '@tldraw/editor'
function getPillCircumference(width: number, height: number) {
@ -200,8 +201,8 @@ export function getCloudArcs(
const radius = Vec2d.Dist(center, leftWigglePoint)
arcs.push({
leftPoint: leftWigglePoint,
rightPoint: rightWigglePoint,
leftPoint: leftWigglePoint.clone(),
rightPoint: rightWigglePoint.clone(),
arcPoint,
center,
radius,
@ -265,11 +266,13 @@ export function cloudSvgPath(
size: TLDefaultSizeStyle
) {
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
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'
@ -289,18 +292,22 @@ export function inkyCloudSvgPath(
}
const mutPoint = (p: Vec2d) => new Vec2d(mut(p.x), mut(p.y))
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 pathB = `M${leftMutPoint.x},${leftMutPoint.y}`
let pathB = `M${toDomPrecision(leftMutPoint.x)},${toDomPrecision(leftMutPoint.y)}`
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 mutArcPoint = mutPoint(arcPoint)
const mutCenter = getCenterOfCircleGivenThreePoints(leftMutPoint, rightMutPoint, mutArcPoint)
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
}
@ -319,7 +326,7 @@ export function pointsOnArc(
const startAngle = Vec2d.Angle(center, startPoint)
const endAngle = Vec2d.Angle(center, endPoint)
const l = shortAngleDist(startAngle, endAngle)
const l = clockwiseAngleDist(startAngle, endAngle)
for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1)

View file

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

View file

@ -1,4 +1,5 @@
import {
Box2d,
TLDefaultColorStyle,
TLDefaultFillStyle,
TLDefaultFontStyle,
@ -27,6 +28,7 @@ export const TextLabel = React.memo(function TextLabel<
align,
verticalAlign,
wrap,
bounds,
}: {
id: T['id']
type: T['type']
@ -38,6 +40,7 @@ export const TextLabel = React.memo(function TextLabel<
wrap?: boolean
text: string
labelColor: TLDefaultColorStyle
bounds?: Box2d
}) {
const {
rInput,
@ -71,48 +74,59 @@ export const TextLabel = React.memo(function TextLabel<
style={{
justifyContent: align === 'middle' || legacyAlign ? 'center' : align,
alignItems: verticalAlign === 'middle' ? 'center' : verticalAlign,
...(bounds
? {
top: bounds.minY,
left: bounds.minX,
width: bounds.width,
height: bounds.height,
position: 'absolute',
}
: {}),
}}
>
<div
className="tl-text-label__inner"
style={{
fontSize: LABEL_FONT_SIZES[size],
lineHeight: LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px',
minHeight: isEmpty ? LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 32 : 0,
minWidth: isEmpty ? 33 : 0,
color: theme[labelColor].solid,
}}
>
<div className="tl-text tl-text-content" dir="ltr">
{finalText}
{isEmpty && !isInteractive ? null : (
<div
className="tl-text-label__inner"
style={{
fontSize: LABEL_FONT_SIZES[size],
lineHeight: LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + 'px',
minHeight: TEXT_PROPS.lineHeight + 32,
minWidth: 0,
color: theme[labelColor].solid,
}}
>
<div className="tl-text tl-text-content" dir="ltr">
{finalText}
</div>
{isInteractive && (
<textarea
ref={rInput}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
autoFocus={isEditing}
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
)}
</div>
{isInteractive && (
<textarea
ref={rInput}
className="tl-text tl-text-input"
name="text"
tabIndex={-1}
autoComplete="false"
autoCapitalize="false"
autoCorrect="false"
autoSave="false"
autoFocus={isEditing}
placeholder=""
spellCheck="true"
wrap="off"
dir="auto"
datatype="wysiwyg"
defaultValue={text}
onFocus={handleFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onContextMenu={stopEventPropagation}
onPointerDown={handleInputPointerDown}
onDoubleClick={handleDoubleClick}
/>
)}
</div>
)}
</div>
)
})