[improvement] Select unfilled shapes by clicking on their stroke (#438)

* removes touch events from middle of shapes

* Improve ellipse

* selectable stroke when not selected, fill when selected

* Update BrushSession.spec.ts

* Fix test
This commit is contained in:
Steve Ruiz 2021-12-07 20:48:56 +00:00 committed by GitHub
parent c67c0871ff
commit 52ae47371d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 185 deletions

View file

@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

View file

@ -39,17 +39,7 @@ export abstract class TLShapeUtil<T extends TLShape, E extends Element = any, M
hitTestBounds = (shape: T, bounds: TLBounds) => {
const shapeBounds = this.getBounds(shape)
if (!shape.rotation) {
return (
Utils.boundsContain(bounds, shapeBounds) ||
Utils.boundsContain(shapeBounds, bounds) ||
Utils.boundsCollide(shapeBounds, bounds)
)
}
const corners = Utils.getRotatedCorners(shapeBounds, shape.rotation)
return (
corners.every((point) => Utils.pointInBounds(point, bounds)) ||
intersectPolylineBounds(corners, bounds).length > 0

View file

@ -218,6 +218,26 @@ const tlcss = css`
contain: layout style size;
}
.tl-stroke-hitarea {
cursor: pointer;
fill: none;
stroke: transparent;
stroke-width: calc(24px * var(--tl-scale));
pointer-events: stroke;
stroke-linecap: round;
stroke-linejoin: round;
}
.tl-fill-hitarea {
cursor: pointer;
fill: transparent;
stroke: transparent;
stroke-width: calc(24px * var(--tl-scale));
pointer-events: all;
stroke-linecap: round;
stroke-linejoin: round;
}
.tl-counter-scaled {
transform: scale(var(--tl-scale));
}
@ -354,6 +374,7 @@ const tlcss = css`
.tl-handle {
pointer-events: all;
cursor: grab;
}
.tl-handle:hover .tl-handle-bg {
@ -365,6 +386,7 @@ const tlcss = css`
}
.tl-handle:active .tl-handle-bg {
cursor: grabbing;
fill: var(--tl-selectFill);
}
@ -389,6 +411,7 @@ const tlcss = css`
stroke-width: calc(3px * var(--tl-scale));
fill: var(--tl-selectFill);
stroke: var(--tl-selected);
pointer-events: none;
}
.tl-centered-g {

View file

@ -6,9 +6,9 @@ describe('Brush session', () => {
const app = new TldrawTestApp()
.loadDocument(mockDocument)
.selectNone()
.movePointer([-10, -10])
.movePointer([-48, -48])
.startSession(SessionType.Brush)
.movePointer([10, 10])
.movePointer([48, 48])
.completeSession()
expect(app.status).toBe(TDStatus.Idle)
expect(app.selectedIds.length).toBe(1)

View file

@ -144,16 +144,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
shaftPath =
arrowDist > 2 ? (
<>
<path
d={path}
fill="none"
strokeWidth={Math.max(8, strokeWidth * 2)}
strokeDasharray="none"
strokeDashoffset="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={styles.stroke}
@ -207,17 +198,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
// Curved arrow path
shaftPath = (
<>
<path
d={path}
fill="none"
stroke="none"
strokeWidth={Math.max(8, strokeWidth)}
strokeDasharray="none"
strokeDashoffset="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
<path className="tl-stroke-hitarea" d={path} />
<path
d={path}
fill={isDraw ? styles.stroke : 'none'}
@ -227,7 +208,7 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
pointerEvents="none"
/>
</>
)
@ -238,30 +219,38 @@ export class ArrowUtil extends TDShapeUtil<T, E> {
<g pointerEvents="none" opacity={isGhost ? GHOSTED_OPACITY : 1}>
{shaftPath}
{startArrowHead && (
<path
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeDashoffset="none"
strokeDasharray="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
<>
<path
className="tl-stroke-hitarea"
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
/>
<path
d={`M ${startArrowHead.left} L ${start.point} ${startArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
</>
)}
{endArrowHead && (
<path
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeDashoffset="none"
strokeDasharray="none"
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="stroke"
/>
<>
<path
className="tl-stroke-hitarea"
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
/>
<path
d={`M ${endArrowHead.left} L ${end.point} ${endArrowHead.right}`}
fill="none"
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeLinejoin="round"
pointerEvents="none"
/>
</>
)}
</g>
</SVGContainer>

View file

@ -51,121 +51,131 @@ export class DrawUtil extends TDShapeUtil<T, E> {
)
}
Component = TDShapeUtil.Component<T, E, TDMeta>(({ shape, meta, isGhost, events }, ref) => {
const { points, style, isComplete } = shape
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, meta, isSelected, isGhost, events }, ref) => {
const { points, style, isComplete } = shape
const polygonPathTDSnapshot = React.useMemo(() => {
return getFillPath(shape)
}, [points, style.size])
const polygonPathTDSnapshot = React.useMemo(() => {
return getFillPath(shape)
}, [points, style.size])
const pathTDSnapshot = React.useMemo(() => {
return style.dash === DashStyle.Draw
? getDrawStrokePathTDSnapshot(shape)
: getSolidStrokePathTDSnapshot(shape)
}, [points, style.size, style.dash, isComplete])
const pathTDSnapshot = React.useMemo(() => {
return style.dash === DashStyle.Draw
? getDrawStrokePathTDSnapshot(shape)
: getSolidStrokePathTDSnapshot(shape)
}, [points, style.size, style.dash, isComplete])
const styles = getShapeStyle(style, meta.isDarkMode)
const { stroke, fill, strokeWidth } = styles
const styles = getShapeStyle(style, meta.isDarkMode)
const { stroke, fill, strokeWidth } = styles
// For very short lines, draw a point instead of a line
const bounds = this.getBounds(shape)
// For very short lines, draw a point instead of a line
const bounds = this.getBounds(shape)
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2
if (verySmall) {
const sw = 1 + strokeWidth
if (verySmall) {
const sw = 1 + strokeWidth
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<circle
r={sw}
fill={stroke}
stroke={stroke}
pointerEvents="all"
opacity={isGhost ? GHOSTED_OPACITY : 1}
/>
</SVGContainer>
)
}
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<circle
r={sw}
fill={stroke}
stroke={stroke}
pointerEvents="all"
opacity={isGhost ? GHOSTED_OPACITY : 1}
/>
</SVGContainer>
)
}
const shouldFill =
style.isFilled &&
points.length > 3 &&
Vec.dist(points[0], points[points.length - 1]) < strokeWidth * 2
const shouldFill =
style.isFilled &&
points.length > 3 &&
Vec.dist(points[0], points[points.length - 1]) < strokeWidth * 2
if (shape.style.dash === DashStyle.Draw) {
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
<path
className={shouldFill && isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={pathTDSnapshot}
/>
{shouldFill && (
<path
d={polygonPathTDSnapshot}
stroke="none"
fill={fill}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
/>
)}
<path
d={pathTDSnapshot}
fill={stroke}
stroke={stroke}
strokeWidth={strokeWidth / 2}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
/>
</g>
</SVGContainer>
)
}
// For solid, dash and dotted lines, draw a regular stroke path
const strokeDasharray = {
[DashStyle.Draw]: 'none',
[DashStyle.Solid]: `none`,
[DashStyle.Dotted]: `0.1 ${strokeWidth * 4}`,
[DashStyle.Dashed]: `${strokeWidth * 4} ${strokeWidth * 4}`,
}[style.dash]
const strokeDashoffset = {
[DashStyle.Draw]: 'none',
[DashStyle.Solid]: `none`,
[DashStyle.Dotted]: `0`,
[DashStyle.Dashed]: `0`,
}[style.dash]
const sw = 1 + strokeWidth * 1.5
if (shape.style.dash === DashStyle.Draw) {
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
{shouldFill && (
<path
d={polygonPathTDSnapshot}
stroke="none"
fill={fill}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="fill"
/>
)}
<path
className={shouldFill && isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={pathTDSnapshot}
/>
<path
d={pathTDSnapshot}
fill={stroke}
stroke={stroke}
strokeWidth={strokeWidth / 2}
fill={shouldFill ? fill : 'none'}
stroke="none"
strokeWidth={Math.min(4, strokeWidth * 2)}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="all"
pointerEvents="none"
/>
<path
d={pathTDSnapshot}
fill="none"
stroke={stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
/>
</g>
</SVGContainer>
)
}
// For solid, dash and dotted lines, draw a regular stroke path
const strokeDasharray = {
[DashStyle.Draw]: 'none',
[DashStyle.Solid]: `none`,
[DashStyle.Dotted]: `0.1 ${strokeWidth * 4}`,
[DashStyle.Dashed]: `${strokeWidth * 4} ${strokeWidth * 4}`,
}[style.dash]
const strokeDashoffset = {
[DashStyle.Draw]: 'none',
[DashStyle.Solid]: `none`,
[DashStyle.Dotted]: `0`,
[DashStyle.Dashed]: `0`,
}[style.dash]
const sw = 1 + strokeWidth * 1.5
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
<path
d={pathTDSnapshot}
fill={shouldFill ? fill : 'none'}
stroke="none"
strokeWidth={Math.min(4, strokeWidth * 2)}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents={shouldFill ? 'all' : 'stroke'}
/>
<path
d={pathTDSnapshot}
fill="none"
stroke={stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="stroke"
/>
</g>
</SVGContainer>
)
})
)
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const { points } = shape

View file

@ -39,7 +39,7 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isGhost, isBinding, meta, events }, ref) => {
({ shape, isGhost, isSelected, isBinding, meta, events }, ref) => {
const {
radius: [radiusX, radiusY],
style,
@ -68,18 +68,25 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
ry={ry + 2}
/>
)}
<ellipse
className={isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radiusX}
cy={radiusY}
rx={radiusX}
ry={radiusY}
/>
<path
d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))}
stroke="none"
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="all"
pointerEvents="none"
/>
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="all"
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
opacity={isGhost ? GHOSTED_OPACITY : 1}
@ -111,16 +118,23 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
/>
)}
<ellipse
className={isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
cx={radiusX}
cy={radiusY}
rx={rx}
ry={ry}
rx={radiusX}
ry={radiusY}
/>
<ellipse
cx={radiusX}
cy={radiusY}
rx={radiusX}
ry={radiusY}
fill={styles.fill}
stroke={styles.stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all"
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
@ -130,7 +144,16 @@ export class EllipseUtil extends TDShapeUtil<T, E> {
)
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
return <path d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))} />
const {
radius: [radiusX, radiusY],
style: { dash },
} = shape
return dash === DashStyle.Draw ? (
<path d={getEllipseIndicatorPathTDSnapshot(shape, this.getCenter(shape))} />
) : (
<ellipse cx={radiusX} cy={radiusY} rx={radiusX} ry={radiusY} />
)
})
hitTestPoint = (shape: T, point: number[]): boolean => {

View file

@ -41,7 +41,7 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
}
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, isBinding, isGhost, meta, events }, ref) => {
({ shape, isBinding, isSelected, isGhost, meta, events }, ref) => {
const { id, size, style } = shape
const styles = getShapeStyle(style, meta.isDarkMode)
@ -50,6 +50,7 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
if (style.dash === DashStyle.Draw) {
const pathTDSnapshot = getRectanglePath(shape)
const indicatorPath = getRectangleIndicatorPathTDSnapshot(shape)
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
@ -63,18 +64,20 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
/>
)}
<path
d={getRectangleIndicatorPathTDSnapshot(shape)}
className={isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
d={indicatorPath}
/>
<path
d={indicatorPath}
fill={style.isFilled ? styles.fill : 'none'}
radius={strokeWidth}
stroke="none"
pointerEvents="all"
pointerEvents="none"
/>
<path
d={pathTDSnapshot}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="all"
pointerEvents="none"
opacity={isGhost ? GHOSTED_OPACITY : 1}
/>
</SVGContainer>
@ -107,9 +110,6 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
y1={start[1]}
x2={end[0]}
y2={end[1]}
stroke={styles.stroke}
strokeWidth={sw}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
@ -118,26 +118,37 @@ export class RectangleUtil extends TDShapeUtil<T, E> {
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
{isBinding && (
<rect
className="tl-binding-indicator"
x={sw / 2 - 32}
y={sw / 2 - 32}
width={w + 64}
height={h + 64}
/>
)}
<rect
className="tl-binding-indicator"
x={sw / 2 - 32}
y={sw / 2 - 32}
width={w + 64}
height={h + 64}
className={isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
x={sw / 2}
y={sw / 2}
width={w}
height={h}
/>
)}
<rect
x={sw / 2}
y={sw / 2}
width={w}
height={h}
fill={styles.fill}
strokeWidth={sw}
stroke="none"
pointerEvents="all"
/>
<g pointerEvents="stroke">{paths}</g>
{style.isFilled && (
<rect
x={sw / 2}
y={sw / 2}
width={w}
height={h}
fill={styles.fill}
pointerEvents="none"
/>
)}
<g pointerEvents="none" stroke={styles.stroke} strokeWidth={sw} strokeLinecap="round">
{paths}
</g>
</g>
</SVGContainer>
)
}