[feature] check-box geo shape (#1330)

This PR adds a `check-box` geo shape. 

![Kapture 2023-05-08 at 15 31
49](https://user-images.githubusercontent.com/23072548/236853749-99ba786f-73a4-4b65-86ca-f2cdac61a903.gif)

It also improves some logic around the `onClick` shape util handler and
some surprisingly related fixes to point hit testing.

### Test Plan

1. Create a geo shape
2. Set it as a checkbox style
3. *easter egg* double click while holding alt to toggle between
check-box and rectangle

- [x] Unit Tests

### Release Note

- Adds checkbox geo shape.
This commit is contained in:
Steve Ruiz 2023-05-09 14:32:04 +01:00 committed by GitHub
parent a8910e5491
commit bb96852b9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 429 additions and 152 deletions

View file

@ -0,0 +1,5 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3H25C26.1046 3 27 3.89543 27 5V25C27 26.1046 26.1046 27 25 27H5C3.89543 27 3 26.1046 3 25V5C3 3.89543 3.89543 3 5 3Z" stroke="black" stroke-width="2"/>
<path d="M8 15L13 22" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M22 8L13 22" stroke="black" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View file

@ -142,7 +142,8 @@
"geo-style.star": "Star",
"geo-style.trapezoid": "Trapezoid",
"geo-style.triangle": "Triangle",
"geo-style.x-box": "X Box",
"geo-style.x-box": "X box",
"geo-style.check-box": "Check box",
"arrowheadStart-style.none": "None",
"arrowheadStart-style.arrow": "Arrow",
"arrowheadStart-style.bar": "Bar",
@ -185,6 +186,7 @@
"tool.trapezoid": "Trapezoid",
"tool.triangle": "Triangle",
"tool.x-box": "X box",
"tool.check-box": "Check box",
"tool.asset": "Asset",
"tool.frame": "Frame",
"tool.note": "Note",

View file

@ -87,6 +87,7 @@ export function getBundlerAssetUrls(opts?: AssetUrlOptions): {
readonly 'geo-arrow-left': string;
readonly 'geo-arrow-right': string;
readonly 'geo-arrow-up': string;
readonly 'geo-check-box': string;
readonly 'geo-diamond': string;
readonly 'geo-ellipse': string;
readonly 'geo-hexagon': string;

View file

@ -97,6 +97,7 @@ import iconsGeoArrowDown from '../icons/icon/geo-arrow-down.svg'
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 iconsGeoDiamond from '../icons/icon/geo-diamond.svg'
import iconsGeoEllipse from '../icons/icon/geo-ellipse.svg'
import iconsGeoHexagon from '../icons/icon/geo-hexagon.svg'
@ -299,6 +300,7 @@ export function getBundlerAssetUrls(opts?: AssetUrlOptions) {
'geo-arrow-left': formatAssetUrl(iconsGeoArrowLeft, opts),
'geo-arrow-right': formatAssetUrl(iconsGeoArrowRight, opts),
'geo-arrow-up': formatAssetUrl(iconsGeoArrowUp, opts),
'geo-check-box': formatAssetUrl(iconsGeoCheckBox, opts),
'geo-diamond': formatAssetUrl(iconsGeoDiamond, opts),
'geo-ellipse': formatAssetUrl(iconsGeoEllipse, opts),
'geo-hexagon': formatAssetUrl(iconsGeoHexagon, opts),

View file

@ -275,6 +275,10 @@ export function getBundlerAssetUrls(opts?: AssetUrlOptions) {
new URL('../icons/icon/geo-arrow-up.svg', import.meta.url).href,
opts
),
'geo-check-box': formatAssetUrl(
new URL('../icons/icon/geo-check-box.svg', import.meta.url).href,
opts
),
'geo-diamond': formatAssetUrl(
new URL('../icons/icon/geo-diamond.svg', import.meta.url).href,
opts

View file

@ -2049,12 +2049,14 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
// (undocumented)
hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean;
// (undocumented)
hitTestPoint(shape: TLGeoShape, point: VecLike): boolean;
// (undocumented)
indicator(shape: TLGeoShape): JSX.Element;
// (undocumented)
onBeforeCreate: (shape: TLGeoShape) => {
props: {
growY: number;
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "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" | "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";
@ -2082,7 +2084,7 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
onBeforeUpdate: (prev: TLGeoShape, next: TLGeoShape) => {
props: {
growY: number;
geo: "arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "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" | "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";
@ -2107,6 +2109,34 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
typeName: "shape";
} | undefined;
// (undocumented)
onDoubleClick: (shape: TLGeoShape) => {
props: {
geo: "check-box";
};
type: "geo";
x: number;
y: number;
rotation: number;
index: string;
parentId: TLParentId;
isLocked: boolean;
id: ID<TLGeoShape>;
typeName: "shape";
} | {
props: {
geo: "rectangle";
};
type: "geo";
x: number;
y: number;
rotation: number;
index: string;
parentId: TLParentId;
isLocked: boolean;
id: ID<TLGeoShape>;
typeName: "shape";
} | undefined;
// (undocumented)
onEditEnd: OnEditEndHandler<TLGeoShape>;
// (undocumented)
onResize: OnResizeHandler<TLGeoShape>;

View file

@ -2277,12 +2277,13 @@ export class App extends EventEmitter {
*/
getShapesAtPoint(point: VecLike): TLShape[] {
return this.shapesArray.filter((shape) => {
// Check the page mask too
const pageMask = this._pageMaskCache.get(shape.id)
if (pageMask) {
const hasHit = pointInPolygon(point, pageMask)
if (!hasHit) return false
return pointInPolygon(point, pageMask)
}
// Otherwise, use the shape's own hit test method
return this.getShapeUtil(shape).hitTestPoint(shape, this.getPointInShapeSpace(shape, point))
})
}

View file

@ -28,6 +28,7 @@ describe('arrowBindingsIndex', () => {
props: {
w: 100,
h: 100,
fill: 'solid',
},
},
{
@ -38,6 +39,7 @@ describe('arrowBindingsIndex', () => {
props: {
w: 100,
h: 100,
fill: 'solid',
},
},
])

View file

@ -4,6 +4,7 @@ import {
linesIntersect,
longAngleDist,
Matrix2d,
pointInPolygon,
shortAngleDist,
toDomPrecision,
Vec2d,
@ -256,77 +257,101 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
const pageTransform = this.app.getPageTransformById(next.id)!
const pointInPageSpace = Matrix2d.applyToPoint(pageTransform, handle)
const target = this.app.inputs.ctrlKey
? undefined
: last(
this.app.getShapesAtPoint(pointInPageSpace).filter((hitShape) => {
if (hitShape.id === shape.id) return
const util = this.app.getShapeUtil(hitShape)
return (
util.canBind(next) &&
util.hitTestPoint(
hitShape,
this.app.getPointInShapeSpace(hitShape, pointInPageSpace)
)
)
})
)
if (target) {
const targetBounds = this.app.getBounds(target)
const pointInTargetSpace = this.app.getPointInShapeSpace(target, pointInPageSpace)
const prevHandle = next.props[handle.id]
const startBindingId =
shape.props.start.type === 'binding' && shape.props.start.boundShapeId
const endBindingId = shape.props.end.type === 'binding' && shape.props.end.boundShapeId
let precise =
// If externally precise, then always precise
isPrecise ||
// If the other handle is bound to the same shape, then precise
((startBindingId || endBindingId) && startBindingId === endBindingId) ||
// If the other shape is not closed, then precise
!this.app.getShapeUtil(target).isClosed(next)
if (
// If we're switching to a new bound shape, then precise only if moving slowly
prevHandle.type === 'point' ||
(prevHandle.type === 'binding' && target.id !== prevHandle.boundShapeId)
) {
precise = this.app.inputs.pointerVelocity.len() < 0.5
}
if (precise) {
// Funky math but we want the snap distance to be 4 at the minimum and either
// 16 or 15% of the smaller dimension of the target shape, whichever is smaller
precise =
Vec2d.Dist(pointInTargetSpace, targetBounds.center) >
Math.max(4, Math.min(Math.min(targetBounds.width, targetBounds.height) * 0.15, 16)) /
this.app.zoomLevel
}
next.props[handle.id] = {
type: 'binding',
boundShapeId: target.id,
normalizedAnchor: precise
? {
x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width,
y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height,
}
: { x: 0.5, y: 0.5 },
isExact: this.app.inputs.altKey,
}
} else {
if (this.app.inputs.ctrlKey) {
next.props[handle.id] = {
type: 'point',
x: handle.x,
y: handle.y,
}
}
} else {
const target = last(
this.app.sortedShapesArray.filter((hitShape) => {
if (hitShape.id === shape.id) {
// We're testing against the arrow
return
}
const util = this.app.getShapeUtil(hitShape)
if (!util.canBind(hitShape)) {
// The shape can't be bound to
return
}
// Check the page mask
const pageMask = this.app.getPageMaskById(hitShape.id)
if (pageMask) {
if (!pointInPolygon(pointInPageSpace, pageMask)) return
}
const pointInTargetSpace = this.app.getPointInShapeSpace(hitShape, pointInPageSpace)
if (util.isClosed(hitShape)) {
// Test the polygon
return pointInPolygon(pointInTargetSpace, util.outline(hitShape))
}
// Test the point using the shape's idea of what a hit is
return util.hitTestPoint(hitShape, pointInTargetSpace)
})
)
if (target) {
const targetBounds = this.app.getBounds(target)
const pointInTargetSpace = this.app.getPointInShapeSpace(target, pointInPageSpace)
const prevHandle = next.props[handle.id]
const startBindingId =
shape.props.start.type === 'binding' && shape.props.start.boundShapeId
const endBindingId = shape.props.end.type === 'binding' && shape.props.end.boundShapeId
let precise =
// If externally precise, then always precise
isPrecise ||
// If the other handle is bound to the same shape, then precise
((startBindingId || endBindingId) && startBindingId === endBindingId) ||
// If the other shape is not closed, then precise
!this.app.getShapeUtil(target).isClosed(next)
if (
// If we're switching to a new bound shape, then precise only if moving slowly
prevHandle.type === 'point' ||
(prevHandle.type === 'binding' && target.id !== prevHandle.boundShapeId)
) {
precise = this.app.inputs.pointerVelocity.len() < 0.5
}
if (precise) {
// Funky math but we want the snap distance to be 4 at the minimum and either
// 16 or 15% of the smaller dimension of the target shape, whichever is smaller
precise =
Vec2d.Dist(pointInTargetSpace, targetBounds.center) >
Math.max(
4,
Math.min(Math.min(targetBounds.width, targetBounds.height) * 0.15, 16)
) /
this.app.zoomLevel
}
next.props[handle.id] = {
type: 'binding',
boundShapeId: target.id,
normalizedAnchor: precise
? {
x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width,
y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height,
}
: { x: 0.5, y: 0.5 },
isExact: this.app.inputs.altKey,
}
} else {
next.props[handle.id] = {
type: 'point',
x: handle.x,
y: handle.y,
}
}
}
break
}
@ -504,12 +529,14 @@ export class TLArrowUtil extends TLShapeUtil<TLArrowShape> {
hitTestPoint(shape: TLArrowShape, point: VecLike): boolean {
const outline = this.outline(shape)
const zoomLevel = this.app.zoomLevel
const offsetDist = this.app.getStrokeWidth(shape.props.size) / zoomLevel
for (let i = 0; i < outline.length - 1; i++) {
const C = outline[i]
const D = outline[i + 1]
if (Vec2d.DistanceToLineSegment(C, D, point) < 4) return true
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
}
return false

View file

@ -7,11 +7,18 @@ import {
linesIntersect,
PI,
PI2,
pointInPolygon,
TAU,
Vec2d,
VecLike,
} from '@tldraw/primitives'
import { geoShapeMigrations, geoShapeTypeValidator, TLDashType, TLGeoShape } from '@tldraw/tlschema'
import {
geoShapeMigrations,
geoShapeTypeValidator,
TLDashType,
TLGeoShape,
TLGeoShapeProps,
} from '@tldraw/tlschema'
import { SVGContainer } from '../../../components/SVGContainer'
import { defineShape } from '../../../config/TLShapeDefinition'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../../../constants'
@ -67,21 +74,51 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
hitTestLineSegment(shape: TLGeoShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
// Check the outline
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
if (linesIntersect(A, B, C, D)) return true
}
if (shape.props.geo === 'x-box') {
const { w, h } = shape.props
if (linesIntersect(A, B, new Vec2d(0, 0), new Vec2d(w, h))) return true
if (linesIntersect(A, B, new Vec2d(0, h), new Vec2d(w, 0))) return true
// Also check lines, if any
const lines = getLines(shape.props, 0)
if (lines !== undefined) {
for (const [C, D] of lines) {
if (linesIntersect(A, B, C, D)) return true
}
}
return false
}
hitTestPoint(shape: TLGeoShape, point: VecLike): boolean {
const outline = this.outline(shape)
if (shape.props.fill === 'none') {
const zoomLevel = this.app.zoomLevel
const offsetDist = this.app.getStrokeWidth(shape.props.size) / zoomLevel
// Check the outline
for (let i = 0; i < outline.length; i++) {
const C = outline[i]
const D = outline[(i + 1) % outline.length]
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
}
// Also check lines, if any
const lines = getLines(shape.props, 1)
if (lines !== undefined) {
for (const [C, D] of lines) {
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
}
}
return false
}
return pointInPolygon(point, outline)
}
getBounds(shape: TLGeoShape) {
return new Box2d(0, 0, shape.props.w, shape.props.h + shape.props.growY)
}
@ -272,6 +309,7 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
new Vec2d(ox, h - oy),
]
}
case 'check-box':
case 'x-box':
case 'rectangle': {
return [new Vec2d(0, 0), new Vec2d(w, 0), new Vec2d(w, h), new Vec2d(0, h)]
@ -361,8 +399,7 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
}
default: {
const outline = this.outline(shape)
const lines =
shape.props.geo === 'x-box' ? getXBoxLines(w, h, strokeWidth, props.dash) : undefined
const lines = getLines(shape.props, strokeWidth)
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
@ -452,9 +489,9 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
path = 'M' + outline[0] + 'L' + outline.slice(1) + 'Z'
}
if (shape.props.geo === 'x-box') {
const lines = getXBoxLines(w, h, strokeWidth, props.dash)
const lines = getLines(shape.props, strokeWidth)
if (lines) {
for (const [A, B] of lines) {
path += `M${A.x},${A.y}L${B.x},${B.y}`
}
@ -555,10 +592,7 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
}
default: {
const outline = this.outline(shape)
const lines =
shape.props.geo === 'x-box'
? getXBoxLines(shape.props.w, shape.props.h, strokeWidth, props.dash)
: undefined
const lines = getLines(shape.props, strokeWidth)
switch (props.dash) {
case 'draw':
@ -857,6 +891,33 @@ export class TLGeoUtil extends TLBoxUtil<TLGeoShape> {
}
}
}
onDoubleClick = (shape: TLGeoShape) => {
// Little easter egg: double-clicking a rectangle / checkbox while
// holding alt will toggle between check-box and rectangle
if (this.app.inputs.altKey) {
switch (shape.props.geo) {
case 'rectangle': {
return {
...shape,
props: {
geo: 'check-box' as const,
},
}
}
case 'check-box': {
return {
...shape,
props: {
geo: 'rectangle' as const,
},
}
}
}
}
return
}
}
function getLabelSize(app: App, shape: TLGeoShape) {
@ -907,6 +968,20 @@ function getLabelSize(app: App, shape: TLGeoShape) {
}
}
function getLines(props: TLGeoShapeProps, sw: number) {
switch (props.geo) {
case 'x-box': {
return getXBoxLines(props.w, props.h, sw, props.dash)
}
case 'check-box': {
return getCheckBoxLines(props.w, props.h)
}
default: {
return undefined
}
}
}
function getXBoxLines(w: number, h: number, sw: number, dash: TLDashType) {
const inset = dash === 'draw' ? 0.62 : 0
@ -925,6 +1000,16 @@ function getXBoxLines(w: number, h: number, sw: number, dash: TLDashType) {
]
}
function getCheckBoxLines(w: number, h: number) {
const size = Math.min(w, h) * 0.82
const ox = (w - size) / 2
const oy = (h - size) / 2
return [
[new Vec2d(ox + size * 0.25, oy + size * 0.52), new Vec2d(ox + size * 0.45, oy + size * 0.82)],
[new Vec2d(ox + size * 0.45, oy + size * 0.82), new Vec2d(ox + size * 0.82, oy + size * 0.22)],
]
}
/** @public */
export const TLGeoShapeDef = defineShape<TLGeoShape, TLGeoUtil>({
type: 'geo',

View file

@ -173,7 +173,9 @@ export class TLLineUtil extends TLShapeUtil<TLLineShape> {
}
hitTestPoint(shape: TLLineShape, point: Vec2d): boolean {
return pointNearToPolyline(point, this.outline(shape))
const zoomLevel = this.app.zoomLevel
const offsetDist = this.app.getStrokeWidth(shape.props.size) / zoomLevel
return pointNearToPolyline(point, this.outline(shape), offsetDist)
}
hitTestLineSegment(shape: TLLineShape, A: VecLike, B: VecLike): boolean {

View file

@ -165,22 +165,23 @@ export class Idle extends StateNode {
const change = util.onDoubleClick?.(shape)
if (change) {
this.app.updateShapes([change])
return
} else if (util.canCrop(shape)) {
// crop on double click
this.app.mark('select and crop')
this.app.select(info.shape?.id)
this.parent.transition('crop', info)
return
}
}
// If the shape can edit, then begin editing
if (util.canEdit(shape)) {
this.startEditingShape(shape, info)
} else {
// If the shape can edit, then begin editing
if (util.canEdit(shape)) {
this.startEditingShape(shape, info)
} else {
// If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and
// begin editing the text shape
this.createTextShapeAtPoint(info)
}
// If the shape's double click handler has not created a change,
// and if the shape cannot edit, then create a text shape and
// begin editing the text shape
this.createTextShapeAtPoint(info)
}
break
}

View file

@ -11,19 +11,12 @@ export class PointingShape extends StateNode {
didSelectOnEnter = false
onEnter = (info: TLPointerEventInfo & { target: 'shape' }) => {
const { shape } = info
if (shape) {
// If a shape has a click handler, then don't do anything here;
// we're run its click handler on pointer up.
const util = this.app.getShapeUtil(shape)
if (util.onClick !== undefined) return
}
this.eventTargetShape = info.shape
this.selectingShape = this.app.getOutermostSelectableShape(info.shape)
if (this.selectingShape.id === this.app.focusLayerId) {
const util = this.app.getShapeUtil(info.shape)
if (util.onClick || this.selectingShape.id === this.app.focusLayerId) {
this.didSelectOnEnter = false
return
}
@ -67,8 +60,9 @@ export class PointingShape extends StateNode {
const change = util.onClick?.(shape)
if (change) {
this.app.updateShapes([change])
this.parent.transition('idle', info)
return
}
return
}
}

View file

@ -253,6 +253,7 @@ export const STYLES: TLStyleCollections = {
{ id: 'arrow-up', type: 'geo', icon: 'geo-arrow-up' },
{ id: 'arrow-down', type: 'geo', icon: 'geo-arrow-down' },
{ id: 'x-box', type: 'geo', icon: 'geo-x-box' },
{ id: 'check-box', type: 'geo', icon: 'geo-check-box' },
],
arrowheadStart: [
{ id: 'none', type: 'arrowheadStart', icon: 'arrowhead-none' },

View file

@ -149,24 +149,25 @@ describe('arrows', () => {
let arrow: TLShape
beforeEach(() => {
// draw a first box
app.setSelectedTool('geo')
app.pointerDown(200, 200)
app.pointerMove(300, 300)
app.pointerUp(300, 300)
app
.setSelectedTool('geo')
.pointerDown(200, 200)
.pointerMove(300, 300)
.pointerUp(300, 300)
.setProp('fill', 'solid')
firstBox = app.onlySelectedShape!
// draw a second box
app.setSelectedTool('geo')
app.pointerDown(400, 400)
app.pointerMove(500, 500)
app.pointerUp(500, 500)
app
.setSelectedTool('geo')
.pointerDown(400, 400)
.pointerMove(500, 500)
.pointerUp(500, 500)
.setProp('fill', 'solid')
secondBox = app.onlySelectedShape!
// draw an arrow from the first box to the second box
app.setSelectedTool('arrow')
app.pointerDown(250, 250)
app.pointerMove(450, 450)
app.pointerUp(450, 450)
app.setSelectedTool('arrow').pointerDown(250, 250).pointerMove(450, 450).pointerUp(450, 450)
arrow = app.onlySelectedShape!
})

View file

@ -65,7 +65,7 @@ beforeEach(() => {
app.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
})
it.only('updates the culling viewport', () => {
it('updates the culling viewport', () => {
app.updateCullingBounds = jest.fn(app.updateCullingBounds)
app.pan(-201, -201)
jest.advanceTimersByTime(500)
@ -74,7 +74,7 @@ it.only('updates the culling viewport', () => {
expect(app.getPageBoundsById(ids.A)).toMatchObject({ x: 100, y: 100, w: 100, h: 100 })
})
it.only('lists shapes in viewport', () => {
it('lists shapes in viewport', () => {
expect(
app.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([
@ -122,25 +122,24 @@ it.only('lists shapes in viewport', () => {
})
it('lists shapes in viewport sorted by id', () => {
// Expect the results to have the correct index
expect(app.renderingShapes.map(({ index }) => index)).toStrictEqual([0, 1, 2])
// Expect the results to be sorted correctly by id
expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([
[ids.A, 0],
[ids.B, 1],
[ids.C, 2],
// A is at the back, then C, then its child ellipse1
[ids.D, 3],
// A is at the back, then B, and then B's children
])
// Send C to the back
// Send B to the back
app.reorderShapes('toBack', [ids.B])
// The items should still be sorted by id
expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([
[ids.A, 2],
[ids.A, 3],
[ids.B, 0],
[ids.C, 1],
// C is now at the back, then its child, and finally A is now in the front
[ids.D, 2],
// B is now at the back, then its children, and finally A is now in the front
])
})

View file

@ -14,7 +14,7 @@ beforeEach(() => {
app.createShapes(createDefaultShapes())
})
it.only('Sets selected shapes', () => {
it('Sets selected shapes', () => {
expect(app.selectedIds).toMatchObject([])
app.setSelectedIds([ids.box1, ids.box2])
expect(app.selectedIds).toMatchObject([ids.box1, ids.box2])

View file

@ -15,7 +15,7 @@ beforeEach(() => {
})
describe('When resizing', () => {
it.only('sets the viewport bounds with App.resize', () => {
it('sets the viewport bounds with App.resize', () => {
app.setScreenBounds({ x: 100, y: 200, w: 700, h: 600 })
expect(app.viewportScreenBounds).toMatchObject({
x: 0,
@ -25,7 +25,7 @@ describe('When resizing', () => {
})
})
it.only('updates the viewport as an ephemeral change', () => {
it('updates the viewport as an ephemeral change', () => {
app.setScreenBounds({ x: 100, y: 200, w: 700, h: 600 })
app.undo() // this should have no effect

View file

@ -169,7 +169,7 @@ describe('When interacting with a shape...', () => {
expect(fnEnd).toHaveBeenCalledTimes(1)
})
it('Fires click events', () => {
it('Uses the shape utils onClick handler', () => {
const util = app.getShapeUtilByDef(TLFrameShapeDef)
const fnClick = jest.fn()
@ -178,7 +178,29 @@ describe('When interacting with a shape...', () => {
app.pointerDown(50, 50, ids.frame1)
app.pointerUp(50, 50, ids.frame1)
// If a shape has an onClick handler, it will not be selected by default
// If a shape has an onClick handler, and if the handler returns nothing,
// then normal selection rules should apply.
expect(app.selectedIds.length).toBe(1)
})
it('Uses the shape utils onClick handler', () => {
const util = app.getShapeUtilByDef(TLFrameShapeDef)
const fnClick = jest.fn((shape: any) => {
return {
...shape,
x: 100,
y: 100,
}
})
util.onClick = fnClick
app.pointerDown(50, 50, ids.frame1)
app.pointerUp(50, 50, ids.frame1)
// If a shape has an onClick handler, and it returns something, then
// it should not be selected.
expect(app.selectedIds.length).toBe(0)
})
})

View file

@ -23,18 +23,27 @@ beforeEach(() => {
type: 'geo',
x: 0,
y: 0,
props: {
fill: 'none',
},
},
{
id: ids.box2,
type: 'geo',
x: 75, // overlapping box1
y: 75,
props: {
fill: 'solid',
},
},
{
id: ids.box3,
type: 'geo',
x: 300,
y: 300,
props: {
fill: 'solid',
},
},
{
id: ids.frame1,
@ -52,6 +61,9 @@ beforeEach(() => {
parentId: ids.frame1,
x: 50,
y: 50, // clipped by frame
props: {
fill: 'solid',
},
},
{
id: ids.draw1,
@ -89,7 +101,7 @@ describe('When clicking', () => {
const shapesBeforeCount = app.shapesArray.length
app.pointerDown(25, 25) // in box1
app.pointerDown(0, 0) // near enough to box1
// Enters the pointing state
app.expectPathToBe('root.eraser.pointing')
@ -129,7 +141,7 @@ describe('When clicking', () => {
const shapesBeforeCount = app.shapesArray.length
app.pointerDown(80, 80) // in box1 AND box2
app.pointerDown(99, 99) // neat to box1 AND in box2
expect(new Set(app.erasingIds)).toEqual(new Set([ids.box1, ids.box2]))
expect(app.erasingIdsSet).toEqual(new Set([ids.box1, ids.box2]))
@ -240,7 +252,7 @@ describe('When clicking', () => {
const shapesBeforeCount = app.shapesArray.length
app.pointerDown(25, 25) // in box1
app.pointerDown(0, 0) // in box1
app.expectPathToBe('root.eraser.pointing')
expect(app.erasingIds).toEqual([ids.box1])
@ -267,7 +279,7 @@ describe('When clicking', () => {
const shapesBeforeCount = app.shapesArray.length
app.pointerDown(25, 25) // in box1
app.pointerDown(0, 0) // near to box1
app.expectPathToBe('root.eraser.pointing')
expect(app.erasingIds).toEqual([ids.box1])
@ -380,6 +392,7 @@ describe('When clicking and dragging', () => {
it('Only erases masked shapes when pointer is inside the mask', () => {
app.setSelectedTool('eraser')
app.pointerDown(425, 0) // Above the masked part of box3
expect(app.erasingIds).toEqual([])
app.pointerMove(425, 500) // Through the masked part of box3
jest.advanceTimersByTime(16)
expect(app.scribble).not.toBe(null)

View file

@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`When resizing a shape with children Resizes a rotated draw shape: draw shape after rotating 1`] = `
Object {
"id": "shape:lineA",
"index": "a3",
"isLocked": false,
"parentId": "shape:boxA",
"props": Object {
"color": "black",
"dash": "draw",
"fill": "none",
"isClosed": false,
"isComplete": false,
"isPen": false,
"opacity": "1",
"segments": Array [
Object {
"points": Array [
Object {
"x": 0,
"y": 0,
"z": 0.5,
},
Object {
"x": 110.00000000000001,
"y": 110.00000000000001,
"z": 0.5,
},
],
"type": "free",
},
],
"size": "m",
},
"rotation": 3.141592653589793,
"type": "draw",
"typeName": "shape",
"x": 100,
"y": 100,
}
`;

View file

@ -112,7 +112,9 @@ describe('creating frames', () => {
})
it('can snap', () => {
app.createShapes([{ type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 50, h: 50 } }])
app.createShapes([
{ type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 50, h: 50, fill: 'solid' } },
])
app.setSelectedTool('frame')
app.pointerDown(100, 100).pointerMove(49, 149)
@ -242,7 +244,9 @@ describe('frame shapes', () => {
const frameId = app.onlySelectedShape!.id
app.createShapes([{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50 } }])
app.createShapes([
{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
])
expect(app.onlySelectedShape!.parentId).toBe(app.currentPageId)
@ -276,7 +280,9 @@ describe('frame shapes', () => {
const frameId = app.onlySelectedShape!.id
// Create a new shape off of the frame
app.createShapes([{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50 } }])
app.createShapes([
{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
])
// It should be a child of the page
expect(app.onlySelectedShape!.parentId).toBe(app.currentPageId)
@ -333,7 +339,9 @@ describe('frame shapes', () => {
app.setSelectedTool('frame')
app.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
app.createShapes([{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50 } }])
app.createShapes([
{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
])
app.setSelectedTool('select')
app.select(ids.boxA)
@ -480,7 +488,7 @@ describe('frame shapes', () => {
const frameId = app.onlySelectedShape!.id
app.setSelectedTool('geo')
app.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)
app.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175).setProp('fill', 'solid')
const boxId = app.onlySelectedShape!.id
app.setSelectedTool('arrow')
@ -587,7 +595,7 @@ describe('frame shapes', () => {
// make a shape inside the frame that extends out of the frame
app.setSelectedTool('geo')
app.pointerDown(150, 150).pointerMove(400, 400).pointerUp(400, 400)
app.pointerDown(150, 150).pointerMove(400, 400).pointerUp(400, 400).setProp('fill', 'solid')
const innerBoxId = app.onlySelectedShape!.id
// Make an arrow that binds to the inner box's bottom right corner
@ -636,15 +644,15 @@ test('arrows bound to a shape within a group within a frame are reparented if th
const frameId = app.onlySelectedShape!.id
app.setSelectedTool('geo')
app.pointerDown(110, 110).pointerMove(120, 120).pointerUp(120, 120)
app.pointerDown(110, 110).pointerMove(120, 120).pointerUp(120, 120).setProp('fill', 'solid')
const boxAId = app.onlySelectedShape!.id
app.setSelectedTool('geo')
app.pointerDown(180, 110).pointerMove(190, 120).pointerUp(190, 120)
app.pointerDown(180, 110).pointerMove(190, 120).pointerUp(190, 120).setProp('fill', 'solid')
const boxBId = app.onlySelectedShape!.id
app.setSelectedTool('geo')
app.pointerDown(160, 160).pointerMove(170, 170).pointerUp(170, 170)
app.pointerDown(160, 160).pointerMove(170, 170).pointerUp(170, 170).setProp('fill', 'solid')
const boxCId = app.onlySelectedShape!.id
app.setSelectedTool('select')

View file

@ -44,6 +44,7 @@ const box = (id: TLShapeId, x: number, y: number, w = 10, h = 10): TLShapePartia
props: {
w,
h,
fill: 'solid',
},
})
const arrow = (id: TLShapeId, start: VecLike, end: VecLike): TLShapePartial => ({

View file

@ -3828,7 +3828,7 @@ it('uses the cross cursor when create resizing', () => {
})
describe('Resizing text from the right edge', () => {
it.only('Resizes text from the right edge', () => {
it('Resizes text from the right edge', () => {
const id = app.createShapeId()
app.createShapes([{ id, type: 'text', props: { text: 'H' } }])
app.updateShapes([{ id, type: 'text', props: { text: 'Hello World' } }]) // auto size

View file

@ -344,7 +344,7 @@ export const geoShapeMigrations: Migrations;
export const geoShapeTypeValidator: T.Validator<TLGeoShape>;
// @internal (undocumented)
export const geoValidator: T.Validator<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
export const geoValidator: T.Validator<"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">;
// @public (undocumented)
export const groupShapeMigrations: Migrations;
@ -490,7 +490,7 @@ export const TL_FILL_TYPES: Set<"none" | "pattern" | "semi" | "solid">;
export const TL_FONT_TYPES: Set<"draw" | "mono" | "sans" | "serif">;
// @public (undocumented)
export const TL_GEO_TYPES: Set<"arrow-down" | "arrow-left" | "arrow-right" | "arrow-up" | "diamond" | "ellipse" | "hexagon" | "octagon" | "oval" | "pentagon" | "rectangle" | "rhombus-2" | "rhombus" | "star" | "trapezoid" | "triangle" | "x-box">;
export const TL_GEO_TYPES: Set<"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">;
// @public (undocumented)
export const TL_HANDLE_TYPES: Set<"create" | "vertex" | "virtual">;

View file

@ -653,6 +653,18 @@ describe('Adding instance_presence to the schema', () => {
})
})
describe('Adding check-box to geo shape', () => {
const { up, down } = geoShapeMigrations.migrators[4]
test('up works as expected', () => {
expect(up({ props: { geo: 'rectangle' } })).toEqual({ props: { geo: 'rectangle' } })
})
test('down works as expected', () => {
expect(down({ props: { geo: 'rectangle' } })).toEqual({ props: { geo: 'rectangle' } })
expect(down({ props: { geo: 'check-box' } })).toEqual({ props: { geo: 'rectangle' } })
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) {

View file

@ -73,13 +73,14 @@ const Versions = {
AddUrlProp: 1,
AddLabelColor: 2,
RemoveJustify: 3,
AddCheckBox: 4,
} as const
/** @public */
export const geoShapeMigrations = defineMigrations({
// STEP 2: Update the current version to point to your latest version
firstVersion: Versions.Initial,
currentVersion: Versions.RemoveJustify,
currentVersion: Versions.AddCheckBox,
migrators: {
// STEP 3: Add an up+down migration for the new version here
[Versions.AddUrlProp]: {
@ -128,5 +129,19 @@ export const geoShapeMigrations = defineMigrations({
return { ...shape }
},
},
[Versions.AddCheckBox]: {
up: (shape) => {
return { ...shape }
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
geo: shape.props.geo === 'check-box' ? 'rectangle' : shape.props.geo,
},
}
},
},
},
})

View file

@ -154,6 +154,7 @@ export const TL_GEO_TYPES = new Set([
'arrow-up',
'arrow-down',
'x-box',
'check-box',
] as const)
/** @public */

File diff suppressed because one or more lines are too long

View file

@ -147,6 +147,7 @@ export type TLTranslationKey =
| 'geo-style.trapezoid'
| 'geo-style.triangle'
| 'geo-style.x-box'
| 'geo-style.check-box'
| 'arrowheadStart-style.none'
| 'arrowheadStart-style.arrow'
| 'arrowheadStart-style.bar'
@ -189,6 +190,7 @@ export type TLTranslationKey =
| 'tool.trapezoid'
| 'tool.triangle'
| 'tool.x-box'
| 'tool.check-box'
| 'tool.asset'
| 'tool.frame'
| 'tool.note'

View file

@ -146,7 +146,8 @@ export const DEFAULT_TRANSLATION = {
'geo-style.star': 'Star',
'geo-style.trapezoid': 'Trapezoid',
'geo-style.triangle': 'Triangle',
'geo-style.x-box': 'X Box',
'geo-style.x-box': 'X box',
'geo-style.check-box': 'Check box',
'arrowheadStart-style.none': 'None',
'arrowheadStart-style.arrow': 'Arrow',
'arrowheadStart-style.bar': 'Bar',
@ -189,6 +190,7 @@ export const DEFAULT_TRANSLATION = {
'tool.trapezoid': 'Trapezoid',
'tool.triangle': 'Triangle',
'tool.x-box': 'X box',
'tool.check-box': 'Check box',
'tool.asset': 'Asset',
'tool.frame': 'Frame',
'tool.note': 'Note',

View file

@ -77,6 +77,7 @@ export type TLUiIconType =
| 'geo-arrow-left'
| 'geo-arrow-right'
| 'geo-arrow-up'
| 'geo-check-box'
| 'geo-diamond'
| 'geo-ellipse'
| 'geo-hexagon'
@ -235,6 +236,7 @@ export const TLUiIconTypes = [
'geo-arrow-left',
'geo-arrow-right',
'geo-arrow-up',
'geo-check-box',
'geo-diamond',
'geo-ellipse',
'geo-hexagon',