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.
pull/1723/head
David Sheldrick 2023-07-07 16:32:08 +01:00 zatwierdzone przez GitHub
rodzic 910be6073f
commit 83a391b46b
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
33 zmienionych plików z 752 dodań i 32 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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' },

Wyświetl plik

@ -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>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Wyświetl plik

@ -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",

Wyświetl plik

@ -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),

Wyświetl plik

@ -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),

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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";

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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))
)
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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;

Wyświetl plik

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

Wyświetl plik

@ -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!]
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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">;

Wyświetl plik

@ -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]

Wyświetl plik

@ -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

Wyświetl plik

@ -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' },

Wyświetl plik

@ -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']),

Wyświetl plik

@ -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'

Wyświetl plik

@ -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',

Wyświetl plik

@ -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',

Wyświetl plik

@ -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({