Add cloud shape (#1708)

![Kapture 2023-07-04 at 16 36
31](https://github.com/tldraw/tldraw/assets/1242537/bcb19959-ac66-46fa-92ea-50fe4692a96c)


### Change Type

- [x] `minor` — New feature


[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Make some cloud shapes, try different sizes, colors, fills.
2. Export cloud shapes to images.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Adds a cloud shape.
This commit is contained in:
David Sheldrick 2023-07-07 16:32:08 +01:00 committed by GitHub
parent 910be6073f
commit 83a391b46b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 752 additions and 32 deletions

View file

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest-16-cores-open
steps:
- name: Check out code
- name: Check out code
uses: actions/checkout@v3
- name: Setup Node.js environment

View file

@ -14,7 +14,7 @@ const clickableShapeCreators = [
{ tool: 'ellipse', shape: 'geo' },
{ tool: 'triangle', shape: 'geo' },
{ tool: 'diamond', shape: 'geo' },
{ tool: 'pentagon', shape: 'geo' },
{ tool: 'cloud', shape: 'geo' },
{ tool: 'hexagon', shape: 'geo' },
// { tool: 'octagon', shape: 'geo' },
{ tool: 'star', shape: 'geo' },
@ -40,7 +40,7 @@ const draggableShapeCreators = [
{ tool: 'ellipse', shape: 'geo' },
{ tool: 'triangle', shape: 'geo' },
{ tool: 'diamond', shape: 'geo' },
{ tool: 'pentagon', shape: 'geo' },
{ tool: 'cloud', shape: 'geo' },
{ tool: 'hexagon', shape: 'geo' },
// { tool: 'octagon', shape: 'geo' },
{ tool: 'star', shape: 'geo' },

View file

@ -0,0 +1,6 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_2338_126559" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3232 12.3709C26.7125 12.788 29.3367 15.6771 29.3367 19.1792C29.3367 22.9679 26.2653 26.0393 22.4766 26.0393H7.52351C3.73481 26.0393 0.663452 22.9679 0.663452 19.1792C0.663452 15.6772 3.28755 12.7881 6.67673 12.3709C6.78558 7.86748 10.4703 4.25024 15 4.25024C19.5296 4.25024 23.2143 7.86747 23.3232 12.3709Z"/>
</mask>
<path d="M23.3232 12.3709L21.3238 12.4192L21.3655 14.145L23.0789 14.3559L23.3232 12.3709ZM6.67673 12.3709L6.92109 14.3559L8.63443 14.145L8.67615 12.4192L6.67673 12.3709ZM23.0789 14.3559C25.478 14.6511 27.3367 16.6996 27.3367 19.1792H31.3367C31.3367 14.6546 27.9469 10.9248 23.5675 10.3858L23.0789 14.3559ZM27.3367 19.1792C27.3367 21.8634 25.1607 24.0393 22.4766 24.0393V28.0393C27.3699 28.0393 31.3367 24.0725 31.3367 19.1792H27.3367ZM22.4766 24.0393H7.52351V28.0393H22.4766V24.0393ZM7.52351 24.0393C4.83938 24.0393 2.66345 21.8634 2.66345 19.1792H-1.33655C-1.33655 24.0725 2.63024 28.0393 7.52351 28.0393V24.0393ZM2.66345 19.1792C2.66345 16.6997 4.52205 14.6512 6.92109 14.3559L6.43237 10.3859C2.05304 10.925 -1.33655 14.6547 -1.33655 19.1792H2.66345ZM8.67615 12.4192C8.75882 8.99859 11.5587 6.25024 15 6.25024V2.25024C9.38196 2.25024 4.81233 6.73637 4.67731 12.3226L8.67615 12.4192ZM15 6.25024C18.4412 6.25024 21.2411 8.99858 21.3238 12.4192L25.3226 12.3225C25.1875 6.73636 20.6179 2.25024 15 2.25024V6.25024Z" fill="black" mask="url(#path-1-inside-1_2338_126559)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -141,6 +141,7 @@
"geo-style.hexagon": "Hexagon",
"geo-style.octagon": "Octagon",
"geo-style.oval": "Oval",
"geo-style.cloud": "Cloud",
"geo-style.pentagon": "Pentagon",
"geo-style.rectangle": "Rectangle",
"geo-style.rhombus-2": "Rhombus 2",

View file

@ -101,6 +101,7 @@ import iconsGeoArrowLeft from './icons/icon/geo-arrow-left.svg'
import iconsGeoArrowRight from './icons/icon/geo-arrow-right.svg'
import iconsGeoArrowUp from './icons/icon/geo-arrow-up.svg'
import iconsGeoCheckBox from './icons/icon/geo-check-box.svg'
import iconsGeoCloud from './icons/icon/geo-cloud.svg'
import iconsGeoDiamond from './icons/icon/geo-diamond.svg'
import iconsGeoEllipse from './icons/icon/geo-ellipse.svg'
import iconsGeoHexagon from './icons/icon/geo-hexagon.svg'
@ -311,6 +312,7 @@ export function getAssetUrlsByImport(opts) {
'geo-arrow-right': formatAssetUrl(iconsGeoArrowRight, opts),
'geo-arrow-up': formatAssetUrl(iconsGeoArrowUp, opts),
'geo-check-box': formatAssetUrl(iconsGeoCheckBox, opts),
'geo-cloud': formatAssetUrl(iconsGeoCloud, opts),
'geo-diamond': formatAssetUrl(iconsGeoDiamond, opts),
'geo-ellipse': formatAssetUrl(iconsGeoEllipse, opts),
'geo-hexagon': formatAssetUrl(iconsGeoHexagon, opts),

View file

@ -97,6 +97,7 @@ export function getAssetUrls(opts) {
'geo-arrow-right': formatAssetUrl('./icons/icon/geo-arrow-right.svg', opts),
'geo-arrow-up': formatAssetUrl('./icons/icon/geo-arrow-up.svg', opts),
'geo-check-box': formatAssetUrl('./icons/icon/geo-check-box.svg', opts),
'geo-cloud': formatAssetUrl('./icons/icon/geo-cloud.svg', opts),
'geo-diamond': formatAssetUrl('./icons/icon/geo-diamond.svg', opts),
'geo-ellipse': formatAssetUrl('./icons/icon/geo-ellipse.svg', opts),
'geo-hexagon': formatAssetUrl('./icons/icon/geo-hexagon.svg', opts),

View file

@ -87,6 +87,7 @@ export type AssetUrls = {
'geo-arrow-right': string
'geo-arrow-up': string
'geo-check-box': string
'geo-cloud': string
'geo-diamond': string
'geo-ellipse': string
'geo-hexagon': string

View file

@ -286,6 +286,10 @@ export function getAssetUrlsByMetaUrl(opts) {
new URL('./icons/icon/geo-check-box.svg', import.meta.url).href,
opts
),
'geo-cloud': formatAssetUrl(
new URL('./icons/icon/geo-cloud.svg', import.meta.url).href,
opts
),
'geo-diamond': formatAssetUrl(
new URL('./icons/icon/geo-diamond.svg', import.meta.url).href,
opts

View file

@ -863,7 +863,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
onBeforeCreate: (shape: TLGeoShape) => {
props: {
growY: number;
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box";
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box";
labelColor: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow";
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow";
fill: "none" | "pattern" | "semi" | "solid";
@ -893,7 +893,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
onBeforeUpdate: (prev: TLGeoShape, next: TLGeoShape) => {
props: {
growY: number;
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box";
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box";
labelColor: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow";
color: "black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow";
fill: "none" | "pattern" | "semi" | "solid";

View file

@ -1,4 +1,4 @@
import { canolicalizeRotation, SelectionEdge, toDomPrecision } from '@tldraw/primitives'
import { canonicalizeRotation, SelectionEdge, toDomPrecision } from '@tldraw/primitives'
import {
getDefaultColorTheme,
TLFrameShape,
@ -69,7 +69,7 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
g.appendChild(rect)
// Text label
const pageRotation = canolicalizeRotation(this.editor.getPageRotationById(shape.id))
const pageRotation = canonicalizeRotation(this.editor.getPageRotationById(shape.id))
// rotate right 45 deg
const offsetRotation = pageRotation + Math.PI / 4
const scaledRotation = (offsetRotation * (2 / Math.PI) + 4) % 4

View file

@ -1,4 +1,4 @@
import { canolicalizeRotation, SelectionEdge, toDomPrecision } from '@tldraw/primitives'
import { canonicalizeRotation, SelectionEdge, toDomPrecision } from '@tldraw/primitives'
import { TLShapeId } from '@tldraw/tlschema'
import { useEffect, useRef } from 'react'
import { useEditor } from '../../../../hooks/useEditor'
@ -18,7 +18,7 @@ export const FrameHeading = function FrameHeading({
}) {
const editor = useEditor()
const pageRotation = canolicalizeRotation(editor.getPageRotationById(id))
const pageRotation = canonicalizeRotation(editor.getPageRotationById(id))
const isEditing = useIsEditing(id)
const rInput = useRef<HTMLInputElement>(null)

View file

@ -38,11 +38,15 @@ import { HyperlinkButton } from '../shared/HyperlinkButton'
import { SvgExportContext } from '../shared/SvgExportContext'
import { TextLabel } from '../shared/TextLabel'
import { useForceSolid } from '../shared/useForceSolid'
import { cloudOutline, cloudSvgPath } from './cloudOutline'
import { DashStyleCloud, DashStyleCloudSvg } from './components/DashStyleCloud'
import { DashStyleEllipse, DashStyleEllipseSvg } from './components/DashStyleEllipse'
import { DashStyleOval, DashStyleOvalSvg } from './components/DashStyleOval'
import { DashStylePolygon, DashStylePolygonSvg } from './components/DashStylePolygon'
import { DrawStyleCloud, DrawStyleCloudSvg } from './components/DrawStyleCloud'
import { DrawStyleEllipseSvg, getEllipseIndicatorPath } from './components/DrawStyleEllipse'
import { DrawStylePolygon, DrawStylePolygonSvg } from './components/DrawStylePolygon'
import { SolidStyleCloud, SolidStyleCloudSvg } from './components/SolidStyleCloud'
import { SolidStyleEllipse, SolidStyleEllipseSvg } from './components/SolidStyleEllipse'
import {
getOvalIndicatorPath,
@ -142,6 +146,9 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
const cy = h / 2
switch (shape.props.geo) {
case 'cloud': {
return cloudOutline(w, h, shape.id, shape.props.size)
}
case 'triangle': {
return [new Vec2d(cx, 0), new Vec2d(w, h), new Vec2d(0, h)]
}
@ -358,6 +365,48 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
const h = props.h + growY
switch (props.geo) {
case 'cloud': {
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
<SolidStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
} else if (dash === 'dashed' || dash === 'dotted') {
return (
<DashStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
dash={dash === 'dashed' ? dash : size === 's' && forceSolid ? 'dashed' : dash}
/>
)
} else if (dash === 'draw') {
return (
<DrawStyleCloud
color={color}
fill={fill}
strokeWidth={strokeWidth}
w={w}
h={h}
id={id}
size={size}
/>
)
}
break
}
case 'ellipse': {
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
@ -471,7 +520,8 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
indicator(shape: TLGeoShape) {
const { id, props } = shape
const { w, h, growY, size } = props
const { w, size } = props
const h = props.h + props.growY
const forceSolid = useForceSolid()
const strokeWidth = STROKE_SIZES[size]
@ -479,13 +529,16 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
switch (props.geo) {
case 'ellipse': {
if (props.dash === 'draw' && !forceSolid) {
return <path d={getEllipseIndicatorPath(id, w, h + growY, strokeWidth)} />
return <path d={getEllipseIndicatorPath(id, w, h, strokeWidth)} />
}
return <ellipse cx={w / 2} cy={(h + growY) / 2} rx={w / 2} ry={(h + growY) / 2} />
return <ellipse cx={w / 2} cy={h / 2} rx={w / 2} ry={h / 2} />
}
case 'oval': {
return <path d={getOvalIndicatorPath(w, h + growY)} />
return <path d={getOvalIndicatorPath(w, h)} />
}
case 'cloud': {
return <path d={cloudSvgPath(w, h, id, size)} />
}
default: {
@ -602,6 +655,50 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
}
break
}
case 'cloud': {
switch (props.dash) {
case 'draw':
svgElm = DrawStyleCloudSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
size: props.size,
theme,
})
break
case 'solid':
svgElm = SolidStyleCloudSvg({
strokeWidth,
w: props.w,
h: props.h,
color: props.color,
fill: props.fill,
size: props.size,
id,
theme,
})
break
default:
svgElm = DashStyleCloudSvg({
id,
strokeWidth,
w: props.w,
h: props.h,
dash: props.dash,
color: props.color,
fill: props.fill,
theme,
size: props.size,
})
}
break
}
default: {
const outline = this.editor.getOutline(shape)
const lines = getLines(shape.props, strokeWidth)

View file

@ -0,0 +1,301 @@
import { PI, Vec2d, getPointOnCircle, shortAngleDist } from '@tldraw/primitives'
import { TLDefaultSizeStyle, Vec2dModel } from '@tldraw/tlschema'
import { rng } from '@tldraw/utils'
function getPillCircumference(width: number, height: number) {
const radius = Math.min(width, height) / 2
const longSide = Math.max(width, height) - radius * 2
return Math.PI * (radius * 2) + 2 * longSide
}
type PillSection =
| {
type: 'straight'
start: Vec2dModel
delta: Vec2dModel
}
| {
type: 'arc'
center: Vec2dModel
startAngle: number
}
export function getPillPoints(width: number, height: number, numPoints: number) {
const radius = Math.min(width, height) / 2
const longSide = Math.max(width, height) - radius * 2
const circumference = Math.PI * (radius * 2) + 2 * longSide
const spacing = circumference / numPoints
const sections: PillSection[] =
width > height
? [
{
type: 'straight',
start: new Vec2d(radius, 0),
delta: new Vec2d(1, 0),
},
{
type: 'arc',
center: new Vec2d(width - radius, radius),
startAngle: -PI / 2,
},
{
type: 'straight',
start: new Vec2d(width - radius, height),
delta: new Vec2d(-1, 0),
},
{
type: 'arc',
center: new Vec2d(radius, radius),
startAngle: PI / 2,
},
]
: [
{
type: 'straight',
start: new Vec2d(width, radius),
delta: new Vec2d(0, 1),
},
{
type: 'arc',
center: new Vec2d(radius, height - radius),
startAngle: 0,
},
{
type: 'straight',
start: new Vec2d(0, height - radius),
delta: new Vec2d(0, -1),
},
{
type: 'arc',
center: new Vec2d(radius, radius),
startAngle: PI,
},
]
let sectionOffset = 0
const points: Vec2d[] = []
for (let i = 0; i < numPoints; i++) {
const section = sections[0]
if (section.type === 'straight') {
points.push(Vec2d.Add(section.start, Vec2d.Mul(section.delta, sectionOffset)))
} else {
points.push(
getPointOnCircle(
section.center.x,
section.center.y,
radius,
section.startAngle + sectionOffset / radius
)
)
}
sectionOffset += spacing
let sectionLength = section.type === 'straight' ? longSide : PI * radius
while (sectionOffset > sectionLength) {
sectionOffset -= sectionLength
sections.push(sections.shift()!)
sectionLength = sections[0].type === 'straight' ? longSide : PI * radius
}
}
return points
}
const switchSize = <T>(size: TLDefaultSizeStyle, s: T, m: T, l: T, xl: T) => {
switch (size) {
case 's':
return s
case 'm':
return m
case 'l':
return l
case 'xl':
return xl
}
}
export function getCloudArcs(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle
) {
const getRandom = rng(seed)
const pillCircumference = getPillCircumference(width, height)
const numBumps = Math.max(Math.ceil(pillCircumference / switchSize(size, 50, 70, 100, 130)), 6)
const targetBumpProtrusion = (pillCircumference / numBumps) * 0.2
// if the aspect ratio is high, innerWidth should be smaller
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1)
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1)
const paddingX = (width - innerWidth) / 2
const paddingY = (height - innerHeight) / 2
const bumpPoints = getPillPoints(innerWidth, innerHeight, numBumps).map((p) => {
return p.addXY(paddingX, paddingY)
})
const maxWiggle = targetBumpProtrusion * 0.3
const adjustedBumpPoints = bumpPoints.map((p) => {
return Vec2d.AddXY(p, getRandom() * maxWiggle, getRandom() * maxWiggle)
})
const arcs: Arc[] = []
for (let i = 0; i < adjustedBumpPoints.length; i++) {
const leftPoint = adjustedBumpPoints[i]
const rightPoint = adjustedBumpPoints[i === adjustedBumpPoints.length - 1 ? 0 : i + 1]
arcs.push(getCloudArc(leftPoint, rightPoint, Math.max(paddingX, paddingY), width, height))
}
return arcs
}
export function getCloudArc(
leftPoint: Vec2d,
rightPoint: Vec2d,
padding: number,
width: number,
height: number
) {
const midPoint = Vec2d.Average([leftPoint, rightPoint])
const offsetAngle = Vec2d.Angle(leftPoint, rightPoint) - Math.PI / 2
const arcPoint = Vec2d.Add(midPoint, Vec2d.FromAngle(offsetAngle, padding))
if (arcPoint.x < 0) {
arcPoint.x = 0
} else if (arcPoint.x > width) {
arcPoint.x = width
}
if (arcPoint.y < 0) {
arcPoint.y = 0
} else if (arcPoint.y > height) {
arcPoint.y = height
}
const center = getCenterOfCircleGivenThreePoints(leftPoint, rightPoint, arcPoint)
const radius = Vec2d.Dist(center, leftPoint)
return {
leftPoint,
rightPoint,
center,
radius,
}
}
type Arc = ReturnType<typeof getCloudArc>
function getCenterOfCircleGivenThreePoints(a: Vec2d, b: Vec2d, c: Vec2d) {
const A = a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y
const B =
(a.x * a.x + a.y * a.y) * (c.y - b.y) +
(b.x * b.x + b.y * b.y) * (a.y - c.y) +
(c.x * c.x + c.y * c.y) * (b.y - a.y)
const C =
(a.x * a.x + a.y * a.y) * (b.x - c.x) +
(b.x * b.x + b.y * b.y) * (c.x - a.x) +
(c.x * c.x + c.y * c.y) * (a.x - b.x)
const x = -B / (2 * A)
const y = -C / (2 * A)
// handle situations where the points are colinear (this happens when the cloud is very small)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return Vec2d.Average([a, b, c])
}
return new Vec2d(x, y)
}
export function cloudOutline(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle
) {
const path: Vec2d[] = []
const arcs = getCloudArcs(width, height, seed, size)
for (const { center, radius, leftPoint, rightPoint } of arcs) {
path.push(...pointsOnArc(leftPoint, rightPoint, center, radius, 10))
}
return path
}
export function cloudSvgPath(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle
) {
const arcs = getCloudArcs(width, height, seed, size)
let path = `M${arcs[0].leftPoint.x},${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 += ' Z'
return path
}
export function inkyCloudSvgPath(
width: number,
height: number,
seed: string,
size: TLDefaultSizeStyle
) {
const getRandom = rng(seed)
const mut = (n: number) => {
const multiplier = size === 's' ? 0.5 : size === 'm' ? 0.7 : size === 'l' ? 0.9 : 1.6
return n + getRandom() * multiplier * 2
}
const arcs = getCloudArcs(width, height, seed, size)
let pathA = `M${arcs[0].leftPoint.x},${arcs[0].leftPoint.y}`
let pathB = `M${mut(arcs[0].leftPoint.x)},${mut(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, center } of arcs) {
pathA += ` A${radius},${radius} 0 0,1 ${rightPoint.x},${rightPoint.y}`
const mutX = mut(rightPoint.x)
const mutY = mut(rightPoint.y)
const mutRadius = Vec2d.Dist(center, { x: mutX, y: mutY })
pathB += ` A${mutRadius},${mutRadius} 0 0,1 ${mutX},${mutY}`
}
return pathA + pathB + ' Z'
}
export function pointsOnArc(
startPoint: Vec2dModel,
endPoint: Vec2dModel,
center: Vec2dModel,
radius: number,
numPoints: number
): Vec2d[] {
const results: Vec2d[] = []
const startAngle = Vec2d.Angle(center, startPoint)
const endAngle = Vec2d.Angle(center, endPoint)
const l = shortAngleDist(startAngle, endAngle)
for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1)
const angle = startAngle + l * t
const point = getPointOnCircle(center.x, center.y, radius, angle)
results.push(point)
}
return results
}

View file

@ -0,0 +1,120 @@
import { Vec2d, canonicalizeRotation } from '@tldraw/primitives'
import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/tlschema'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { getPerfectDashProps } from '../../shared/getPerfectDashProps'
import { cloudSvgPath, getCloudArcs } from '../cloudOutline'
export const DashStyleCloud = React.memo(function DashStylePolygon({
dash,
fill,
color,
strokeWidth,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'dash' | 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const innerPath = cloudSvgPath(w, h, id, size)
const arcs = getCloudArcs(w, h, id, size)
return (
<>
<ShapeFill d={innerPath} fill={fill} color={color} />
<g strokeWidth={strokeWidth} stroke={theme[color].solid} fill="none" pointerEvents="all">
{arcs.map(({ leftPoint, rightPoint, center, radius }, i) => {
const angle = canonicalizeRotation(
canonicalizeRotation(Vec2d.Angle(center, rightPoint)) -
canonicalizeRotation(Vec2d.Angle(center, leftPoint))
)
const arcLength = radius * angle
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
arcLength,
strokeWidth,
{
style: dash,
start: 'outset',
end: 'outset',
}
)
return (
<path
key={i}
d={`M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}`}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</>
)
})
export function DashStyleCloudSvg({
dash,
fill,
color,
theme,
strokeWidth,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'dash' | 'fill' | 'color' | 'w' | 'h' | 'size'> & {
id: TLShapeId
strokeWidth: number
theme: TLDefaultColorTheme
}) {
const innerPath = cloudSvgPath(w, h, id, size)
const arcs = getCloudArcs(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
for (const { leftPoint, rightPoint, center, radius } of arcs) {
const angle = canonicalizeRotation(
canonicalizeRotation(Vec2d.Angle(center, rightPoint)) -
canonicalizeRotation(Vec2d.Angle(center, leftPoint))
)
const arcLength = radius * angle
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(arcLength, strokeWidth, {
style: dash,
start: 'outset',
end: 'outset',
})
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute(
'd',
`M${leftPoint.x},${leftPoint.y}A${radius},${radius},0,0,1,${rightPoint.x},${rightPoint.y}`
)
path.setAttribute('stroke-dasharray', strokeDasharray.toString())
path.setAttribute('stroke-dashoffset', strokeDashoffset.toString())
strokeElement.appendChild(path)
}
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: innerPath,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -0,0 +1,65 @@
import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/tlschema'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { inkyCloudSvgPath } from '../cloudOutline'
export const DrawStyleCloud = React.memo(function StyleCloud({
fill,
color,
strokeWidth,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const path = inkyCloudSvgPath(w, h, id, size)
return (
<>
<ShapeFill d={path} fill={fill} color={color} />
<path d={path} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})
export function DrawStyleCloudSvg({
fill,
color,
strokeWidth,
theme,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
theme: TLDefaultColorTheme
id: TLShapeId
}) {
const pathData = inkyCloudSvgPath(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', pathData)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: pathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -0,0 +1,65 @@
import { TLDefaultColorTheme, TLGeoShape, TLShapeId } from '@tldraw/tlschema'
import * as React from 'react'
import {
ShapeFill,
getShapeFillSvg,
getSvgWithShapeFill,
useDefaultColorTheme,
} from '../../shared/ShapeFill'
import { cloudSvgPath } from '../cloudOutline'
export const SolidStyleCloud = React.memo(function SolidStyleCloud({
fill,
color,
strokeWidth,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
id: TLShapeId
}) {
const theme = useDefaultColorTheme()
const path = cloudSvgPath(w, h, id, size)
return (
<>
<ShapeFill d={path} fill={fill} color={color} />
<path d={path} stroke={theme[color].solid} strokeWidth={strokeWidth} fill="none" />
</>
)
})
export function SolidStyleCloudSvg({
fill,
color,
strokeWidth,
theme,
w,
h,
id,
size,
}: Pick<TLGeoShape['props'], 'fill' | 'color' | 'w' | 'h' | 'size'> & {
strokeWidth: number
theme: TLDefaultColorTheme
id: TLShapeId
}) {
const pathData = cloudSvgPath(w, h, id, size)
const strokeElement = document.createElementNS('http://www.w3.org/2000/svg', 'path')
strokeElement.setAttribute('d', pathData)
strokeElement.setAttribute('stroke-width', strokeWidth.toString())
strokeElement.setAttribute('stroke', theme[color].solid)
strokeElement.setAttribute('fill', 'none')
// Get the fill element, if any
const fillElement = getShapeFillSvg({
d: pathData,
fill,
color,
theme,
})
return getSvgWithShapeFill(strokeElement, fillElement)
}

View file

@ -81,7 +81,11 @@ export class Pointing extends StateNode {
if (!shape) return
const bounds =
shape.props.geo === 'star' ? getStarBounds(5, 200, 200) : new Box2d(0, 0, 200, 200)
shape.props.geo === 'star'
? getStarBounds(5, 200, 200)
: shape.props.geo === 'cloud'
? new Box2d(0, 0, 300, 180)
: new Box2d(0, 0, 200, 200)
const delta = this.editor.getDeltaInParentSpace(shape, bounds.center)
this.editor.select(id)

View file

@ -1,5 +1,5 @@
import {
canolicalizeRotation,
canonicalizeRotation,
EPSILON,
PI,
PI2,
@ -332,8 +332,8 @@ describe('When resizing mulitple shapes...', () => {
.pointerMove(rotateEnd.x, rotateEnd.y)
.pointerUp()
expect(canolicalizeRotation(shapeA.rotation) % Math.PI).toBeCloseTo(
canolicalizeRotation(rotation) % Math.PI
expect(canonicalizeRotation(shapeA.rotation) % Math.PI).toBeCloseTo(
canonicalizeRotation(rotation) % Math.PI
)
expect(editor.getPageRotation(shapeB)).toBeCloseTo(rotation + rotationB)
expect(editor.getPageRotation(shapeC)).toBeCloseTo(rotation + rotationB)
@ -589,7 +589,7 @@ describe('Reisizing a selection of multiple shapes', () => {
editor.pointerUp(20, 20, { shiftKey: false })
jest.advanceTimersByTime(200)
expect(editor.getShapeById(ids.boxB)!.rotation).toBeCloseTo(canolicalizeRotation(-PI / 2))
expect(editor.getShapeById(ids.boxB)!.rotation).toBeCloseTo(canonicalizeRotation(-PI / 2))
editor.select(ids.boxA, ids.boxB)
// shrink
@ -2326,7 +2326,7 @@ describe('snapping while resizing a shape that has been rotated by multiples of
expect(editor.getPageBoundsById(ids.boxX)!.w).toBeCloseTo(60)
expect(editor.getPageBoundsById(ids.boxX)!.h).toBeCloseTo(60)
expect(editor.getShapeById(ids.boxX)!.rotation).toEqual(
canolicalizeRotation(((PI / 2) * times) % (PI * 2))
canonicalizeRotation(((PI / 2) * times) % (PI * 2))
)
}

View file

@ -1,4 +1,4 @@
import { canolicalizeRotation, Matrix2d, Vec2d } from '@tldraw/primitives'
import { canonicalizeRotation, Matrix2d, Vec2d } from '@tldraw/primitives'
import { isShapeId, TLShape, TLShapePartial } from '@tldraw/tlschema'
import { structuredClone } from '@tldraw/utils'
import { Editor } from '../editor/Editor'
@ -83,7 +83,7 @@ export function applyRotationToSnapshotShapes({
Matrix2d.Inverse(parentTransform),
newPagePoint
)
const newRotation = canolicalizeRotation(shape.rotation + delta)
const newRotation = canonicalizeRotation(shape.rotation + delta)
return {
id: shape.id,

View file

@ -130,7 +130,7 @@ export class Box2d {
}
// @public (undocumented)
export function canolicalizeRotation(a: number): number;
export function canonicalizeRotation(a: number): number;
// @public
export function clamp(n: number, min: number): number;
@ -718,6 +718,8 @@ export class Vec2d {
// (undocumented)
static From({ x, y, z }: Vec2dModel): Vec2d;
// (undocumented)
static FromAngle(r: number, length?: number): Vec2d;
// (undocumented)
static FromArray(v: number[]): Vec2d;
// (undocumented)
static Len(A: VecLike): number;

View file

@ -55,7 +55,7 @@ export {
angleDelta,
approximately,
areAnglesCompatible,
canolicalizeRotation,
canonicalizeRotation,
clamp,
clampRadians,
degreesToRadians,

View file

@ -484,6 +484,10 @@ export class Vec2d {
return r
}
static FromAngle(r: number, length = 1) {
return new Vec2d(Math.cos(r) * length, Math.sin(r) * length)
}
static ToArray(A: VecLike) {
return [A.x, A.y, A.z!]
}

View file

@ -88,7 +88,7 @@ export function perimeterOfEllipse(rx: number, ry: number): number {
* @returns A number between 0 and 2 * PI
* @public
*/
export function canolicalizeRotation(a: number) {
export function canonicalizeRotation(a: number) {
a = a % PI2
if (a < 0) {
a = a + PI2

View file

@ -457,14 +457,14 @@ export const frameShapeProps: {
};
// @public (undocumented)
export const GeoShapeGeoStyle: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
export const GeoShapeGeoStyle: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
// @internal (undocumented)
export const geoShapeMigrations: Migrations;
// @public (undocumented)
export const geoShapeProps: {
geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
geo: EnumStyleProp<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "check-box" | "cloud" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
labelColor: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow">;
color: EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow">;
fill: EnumStyleProp<"none" | "pattern" | "semi" | "solid">;

View file

@ -15,7 +15,7 @@ import { arrowShapeMigrations } from './shapes/TLArrowShape'
import { bookmarkShapeMigrations } from './shapes/TLBookmarkShape'
import { drawShapeMigrations } from './shapes/TLDrawShape'
import { embedShapeMigrations } from './shapes/TLEmbedShape'
import { geoShapeMigrations } from './shapes/TLGeoShape'
import { GeoShapeVersions, geoShapeMigrations } from './shapes/TLGeoShape'
import { imageShapeMigrations } from './shapes/TLImageShape'
import { noteShapeMigrations } from './shapes/TLNoteShape'
import { textShapeMigrations } from './shapes/TLTextShape'
@ -707,6 +707,22 @@ describe('Migrate GeoShape legacy horizontal alignment', () => {
})
})
describe('adding cloud shape', () => {
const { up, down } = geoShapeMigrations.migrators[GeoShapeVersions.AddCloud]
test('up does nothing', () => {
expect(up({ props: { geo: 'rectangle' } })).toEqual({
props: { geo: 'rectangle' },
})
})
test('down converts clouds to rectangles', () => {
expect(down({ props: { geo: 'cloud' } })).toEqual({
props: { geo: 'rectangle' },
})
})
})
describe('Migrate NoteShape legacy horizontal alignment', () => {
const { up, down } = noteShapeMigrations.migrators[3]

View file

@ -17,6 +17,7 @@ import { ShapePropsType, TLBaseShape } from './TLBaseShape'
export const GeoShapeGeoStyle = StyleProp.defineEnum('tldraw:geo', {
defaultValue: 'rectangle',
values: [
'cloud',
'rectangle',
'ellipse',
'triangle',
@ -72,11 +73,14 @@ const Versions = {
AddCheckBox: 4,
AddVerticalAlign: 5,
MigrateLegacyAlign: 6,
AddCloud: 7,
} as const
export { Versions as GeoShapeVersions }
/** @internal */
export const geoShapeMigrations = defineMigrations({
currentVersion: Versions.MigrateLegacyAlign,
currentVersion: Versions.AddCloud,
migrators: {
[Versions.AddUrlProp]: {
up: (shape) => {
@ -202,5 +206,21 @@ export const geoShapeMigrations = defineMigrations({
}
},
},
[Versions.AddCloud]: {
up: (shape) => {
return shape
},
down: (shape) => {
if (shape.props.geo === 'cloud') {
return {
...shape,
props: {
...shape.props,
geo: 'rectangle',
},
}
}
},
},
},
})

File diff suppressed because one or more lines are too long

View file

@ -55,6 +55,7 @@ export const STYLES = {
geo: [
{ value: 'rectangle', icon: 'geo-rectangle' },
{ value: 'ellipse', icon: 'geo-ellipse' },
{ value: 'cloud', icon: 'geo-cloud' },
{ value: 'triangle', icon: 'geo-triangle' },
{ value: 'diamond', icon: 'geo-diamond' },
{ value: 'pentagon', icon: 'geo-pentagon' },

View file

@ -61,8 +61,8 @@ export function ToolbarSchemaProvider({ overrides, children }: TLUiToolbarSchema
toolbarItem(tools['triangle']),
toolbarItem(tools['trapezoid']),
toolbarItem(tools['rhombus']),
toolbarItem(tools['pentagon']),
toolbarItem(tools['hexagon']),
toolbarItem(tools['cloud']),
// toolbarItem(tools['octagon']),
toolbarItem(tools['star']),
toolbarItem(tools['oval']),

View file

@ -145,6 +145,7 @@ export type TLUiTranslationKey =
| 'geo-style.hexagon'
| 'geo-style.octagon'
| 'geo-style.oval'
| 'geo-style.cloud'
| 'geo-style.pentagon'
| 'geo-style.rectangle'
| 'geo-style.rhombus-2'

View file

@ -145,6 +145,7 @@ export const DEFAULT_TRANSLATION = {
'geo-style.hexagon': 'Hexagon',
'geo-style.octagon': 'Octagon',
'geo-style.oval': 'Oval',
'geo-style.cloud': 'Cloud',
'geo-style.pentagon': 'Pentagon',
'geo-style.rectangle': 'Rectangle',
'geo-style.rhombus-2': 'Rhombus 2',

View file

@ -79,6 +79,7 @@ export type TLUiIconType =
| 'geo-arrow-right'
| 'geo-arrow-up'
| 'geo-check-box'
| 'geo-cloud'
| 'geo-diamond'
| 'geo-ellipse'
| 'geo-hexagon'
@ -243,6 +244,7 @@ export const iconTypes = [
'geo-arrow-right',
'geo-arrow-up',
'geo-check-box',
'geo-cloud',
'geo-diamond',
'geo-ellipse',
'geo-hexagon',

View file

@ -3,6 +3,7 @@ import fetch from 'cross-fetch'
import { assert } from 'node:console'
import { parse } from 'semver'
import { exec } from './lib/exec'
import { BUBLIC_ROOT } from './lib/file'
import { nicelog } from './lib/nicelog'
import { getLatestVersion, publish, setAllVersions } from './lib/publishing'
import { getAllWorkspacePackages } from './lib/workspace'
@ -57,7 +58,12 @@ async function main() {
packageJsonFilesToAdd.push(`${workspace.relativePath}/package.json`)
}
}
await exec('git', ['add', 'lerna.json', ...packageJsonFilesToAdd])
await exec('git', [
'add',
'lerna.json',
...packageJsonFilesToAdd,
BUBLIC_ROOT + '/packages/*/src/version.ts',
])
// this creates a new commit
await auto.changelog({