[1/3] initial highlighter shape/tool (#1401)

This diff adds an initial version of the highlighter shape. At this
stage, it's a complete copy of the draw tool minus the following
features:
* Fills
* Stroke types
* Closed shapes

I've created a new shape util (a copy-paste of the draw one with stuff
renamed/deleted) but reused the state chart nodes for the draw shape.
Currently this new tool looks exactly like the draw tool, but that'll be
changing soon!

![Kapture 2023-05-17 at 15 37
33](https://github.com/tldraw/tldraw/assets/1489520/982e78f4-6495-4a68-aa51-c8f7b5bcdd01)

The UI here is extremely WIP. The highlighter tool is behind a feature
flag, but once enabled is accessible through the tool bar. There's a
first-draft highlighter icon (i didn't spend much time on this, it's not
super legible on non-retina displays yet imo), and the tool is bound to
the `i` key (any better suggestions? `h` is taken by the hand tool)

### The plan
1. initial highlighter shape/tool #1401 **>you are here<**
2. sandwich rendering for highlighter shapes #1418  
3. shape styling - new colours and sizes, lightweight perfect freehand
changes

### Change Type
- [x] `minor` — New Feature

### Test Plan
(not yet)

### Release Notes

[internal only change layout ground work for highlighter]
This commit is contained in:
alex 2023-06-01 13:46:13 +01:00 committed by GitHub
parent 2992ad85d9
commit 674a829d1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 732 additions and 275 deletions

View file

@ -0,0 +1,4 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 24.5C8 24 9.3 23 10.5 23C11.3229 23 12.1927 23.1567 12.8195 23.309C13.2406 23.4113 13.6936 23.3064 14 23V23M7.5 24.5L6.5 23.5L5.5 22.5M7.5 24.5L6.5 25.5L3.5 24.5L5.5 22.5M7.5 24.5L5.5 22.5M5.5 22.5C6 22 7 20.7 7 19.5C7 18.6771 6.84326 17.8073 6.69101 17.1805C6.5887 16.7594 6.69357 16.3064 7 16V16M7 16L18.5858 4.41421C19.3668 3.63317 20.6332 3.63317 21.4142 4.41421L25.5858 8.58579C26.3668 9.36684 26.3668 10.6332 25.5858 11.4142L14 23M7 16L14 23" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 24.5L5.5 22.5L7.5 24.5L6.5 25.5L3.5 24.5Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

View file

@ -180,6 +180,7 @@
"tool.diamond": "Diamond",
"tool.ellipse": "Ellipse",
"tool.hexagon": "Hexagon",
"tool.highlight": "Highlight",
"tool.line": "Line",
"tool.octagon": "Octagon",
"tool.oval": "Oval",

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View file

@ -150,6 +150,7 @@ export function getAssetUrlsByImport(opts?: AssetUrlOptions): {
'tool-eraser': string
'tool-frame': string
'tool-hand': string
'tool-highlight': string
'tool-highlighter': string
'tool-laser': string
'tool-line': string

View file

@ -161,6 +161,7 @@ import iconsToolEmbed from './icons/icon/tool-embed.svg'
import iconsToolEraser from './icons/icon/tool-eraser.svg'
import iconsToolFrame from './icons/icon/tool-frame.svg'
import iconsToolHand from './icons/icon/tool-hand.svg'
import iconsToolHighlight from './icons/icon/tool-highlight.svg'
import iconsToolHighlighter from './icons/icon/tool-highlighter.svg'
import iconsToolLaser from './icons/icon/tool-laser.svg'
import iconsToolLine from './icons/icon/tool-line.svg'
@ -392,6 +393,7 @@ export function getAssetUrlsByImport(opts) {
'tool-eraser': formatAssetUrl(iconsToolEraser, opts),
'tool-frame': formatAssetUrl(iconsToolFrame, opts),
'tool-hand': formatAssetUrl(iconsToolHand, opts),
'tool-highlight': formatAssetUrl(iconsToolHighlight, opts),
'tool-highlighter': formatAssetUrl(iconsToolHighlighter, opts),
'tool-laser': formatAssetUrl(iconsToolLaser, opts),
'tool-line': formatAssetUrl(iconsToolLine, opts),

View file

@ -150,6 +150,7 @@ export function getAssetUrlsByMetaUrl(opts?: AssetUrlOptions): {
'tool-eraser': string
'tool-frame': string
'tool-hand': string
'tool-highlight': string
'tool-highlighter': string
'tool-laser': string
'tool-line': string

View file

@ -494,6 +494,10 @@ export function getAssetUrlsByMetaUrl(opts) {
new URL('./icons/icon/tool-hand.svg', import.meta.url).href,
opts
),
'tool-highlight': formatAssetUrl(
new URL('./icons/icon/tool-highlight.svg', import.meta.url).href,
opts
),
'tool-highlighter': formatAssetUrl(
new URL('./icons/icon/tool-highlighter.svg', import.meta.url).href,
opts

View file

@ -64,6 +64,7 @@ import { TLFrameShape } from '@tldraw/tlschema';
import { TLGeoShape } from '@tldraw/tlschema';
import { TLGroupShape } from '@tldraw/tlschema';
import { TLHandle } from '@tldraw/tlschema';
import { TLHighlightShape } from '@tldraw/tlschema';
import { TLImageAsset } from '@tldraw/tlschema';
import { TLImageShape } from '@tldraw/tlschema';
import { TLInstance } from '@tldraw/tlschema';
@ -725,6 +726,7 @@ export const EVENT_NAME_MAP: Record<Exclude<TLEventName, TLPinchEventName>, keyo
// @internal (undocumented)
export const featureFlags: {
peopleMenu: DebugFlag<boolean>;
highlighterTool: DebugFlag<boolean>;
};
// @public
@ -2204,6 +2206,42 @@ export class TLGroupUtil extends TLShapeUtil<TLGroupShape> {
static type: string;
}
// @public (undocumented)
export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
// (undocumented)
defaultProps(): TLHighlightShape['props'];
// (undocumented)
expandSelectionOutlinePx(shape: TLHighlightShape): number;
// (undocumented)
getBounds(shape: TLHighlightShape): Box2d;
// (undocumented)
getCenter(shape: TLHighlightShape): Vec2d;
// (undocumented)
getOutline(shape: TLHighlightShape): Vec2d[];
// (undocumented)
hideResizeHandles: (shape: TLHighlightShape) => boolean;
// (undocumented)
hideRotateHandle: (shape: TLHighlightShape) => boolean;
// (undocumented)
hideSelectionBoundsBg: (shape: TLHighlightShape) => boolean;
// (undocumented)
hideSelectionBoundsFg: (shape: TLHighlightShape) => boolean;
// (undocumented)
hitTestLineSegment(shape: TLHighlightShape, A: VecLike, B: VecLike): boolean;
// (undocumented)
hitTestPoint(shape: TLHighlightShape, point: VecLike): boolean;
// (undocumented)
indicator(shape: TLHighlightShape): JSX.Element;
// (undocumented)
onResize: OnResizeHandler<TLHighlightShape>;
// (undocumented)
render(shape: TLHighlightShape): JSX.Element;
// (undocumented)
toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors): SVGPathElement;
// (undocumented)
static type: string;
}
// @public (undocumented)
export type TLHistoryEntry = TLCommand | TLMark;

View file

@ -35,6 +35,7 @@ export { TLEmbedUtil } from './lib/app/shapeutils/TLEmbedUtil/TLEmbedUtil'
export { TLFrameUtil } from './lib/app/shapeutils/TLFrameUtil/TLFrameUtil'
export { TLGeoUtil } from './lib/app/shapeutils/TLGeoUtil/TLGeoUtil'
export { TLGroupUtil } from './lib/app/shapeutils/TLGroupUtil/TLGroupUtil'
export { TLHighlightUtil } from './lib/app/shapeutils/TLHighlightUtil/TLHighlightUtil'
export { TLImageUtil } from './lib/app/shapeutils/TLImageUtil/TLImageUtil'
export { TLLineUtil, getSplineForLineShape } from './lib/app/shapeutils/TLLineUtil/TLLineUtil'
export { TLNoteUtil } from './lib/app/shapeutils/TLNoteUtil/TLNoteUtil'

View file

@ -4844,6 +4844,7 @@ export class App extends EventEmitter<TLEventMap> {
if (!prev) return null
let newRecord = null as null | TLShape
for (const [k, v] of Object.entries(partial)) {
if (v === undefined) continue
switch (k) {
case 'id':
case 'type':
@ -4857,7 +4858,12 @@ export class App extends EventEmitter<TLEventMap> {
}
if (k === 'props') {
newRecord!.props = { ...prev.props, ...(v as any) }
const nextProps = { ...prev.props } as Record<string, unknown>
for (const [propKey, propValue] of Object.entries(v as object)) {
if (propValue === undefined) continue
nextProps[propKey] = propValue
}
newRecord!.props = nextProps
} else {
;(newRecord as any)[k] = v
}

View file

@ -137,7 +137,7 @@ export class TLDrawUtil extends TLShapeUtil<TLDrawShape> {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(shape, sw, showAsComplete, forceSolid)
const options = getFreehandOptions(shape.props, sw, showAsComplete, forceSolid)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
@ -201,7 +201,7 @@ export class TLDrawUtil extends TLShapeUtil<TLDrawShape> {
}
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
const options = getFreehandOptions(shape, sw, showAsComplete, true)
const options = getFreehandOptions(shape.props, sw, showAsComplete, true)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
@ -224,7 +224,7 @@ export class TLDrawUtil extends TLShapeUtil<TLDrawShape> {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(shape, sw, showAsComplete, false)
const options = getFreehandOptions(shape.props, sw, showAsComplete, false)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1

View file

@ -1,5 +1,5 @@
import { EASINGS, PI, SIN, StrokeOptions, Vec2d } from '@tldraw/primitives'
import { TLDrawShape, TLDrawShapeSegment } from '@tldraw/tlschema'
import { TLDashType, TLDrawShape, TLDrawShapeSegment } from '@tldraw/tlschema'
const PEN_EASING = (t: number) => t * 0.65 + SIN((t * PI) / 2) * 0.35
@ -37,7 +37,7 @@ const solidSettings = (strokeWidth: number): StrokeOptions => {
}
export function getFreehandOptions(
shape: TLDrawShape,
shapeProps: { dash: TLDashType; isPen: boolean; isComplete: boolean },
strokeWidth: number,
forceComplete: boolean,
forceSolid: boolean
@ -45,12 +45,12 @@ export function getFreehandOptions(
return {
...(forceSolid
? solidSettings(strokeWidth)
: shape.props.dash === 'draw'
? shape.props.isPen
: shapeProps.dash === 'draw'
? shapeProps.isPen
? realPressureSettings(strokeWidth)
: simulatePressureSettings(strokeWidth)
: solidSettings(strokeWidth)),
last: shape.props.isComplete || forceComplete,
last: shapeProps.isComplete || forceComplete,
}
}

View file

@ -0,0 +1,254 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
Box2d,
getStrokeOutlinePoints,
getStrokePoints,
linesIntersect,
setStrokePointRadii,
Vec2d,
VecLike,
} from '@tldraw/primitives'
import { TLDrawShapeSegment, TLHighlightShape } from '@tldraw/tlschema'
import { last, rng } from '@tldraw/utils'
import { SVGContainer } from '../../../components/SVGContainer'
import { getSvgPathFromStroke, getSvgPathFromStrokePoints } from '../../../utils/svg'
import { ShapeFill } from '../shared/ShapeFill'
import { TLExportColors } from '../shared/TLExportColors'
import { useForceSolid } from '../shared/useForceSolid'
import { getFreehandOptions, getPointsFromSegments } from '../TLDrawUtil/getPath'
import { OnResizeHandler, TLShapeUtil } from '../TLShapeUtil'
/** @public */
export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
static type = 'highlight'
hideResizeHandles = (shape: TLHighlightShape) => this.getIsDot(shape)
hideRotateHandle = (shape: TLHighlightShape) => this.getIsDot(shape)
hideSelectionBoundsBg = (shape: TLHighlightShape) => this.getIsDot(shape)
hideSelectionBoundsFg = (shape: TLHighlightShape) => this.getIsDot(shape)
override defaultProps(): TLHighlightShape['props'] {
return {
segments: [],
color: 'black',
size: 'm',
opacity: '1',
isComplete: false,
isPen: false,
}
}
private getIsDot(shape: TLHighlightShape) {
return shape.props.segments.length === 1 && shape.props.segments[0].points.length < 2
}
getBounds(shape: TLHighlightShape) {
return Box2d.FromPoints(this.outline(shape))
}
getOutline(shape: TLHighlightShape) {
return getPointsFromSegments(shape.props.segments)
}
getCenter(shape: TLHighlightShape): Vec2d {
return this.bounds(shape).center
}
hitTestPoint(shape: TLHighlightShape, point: VecLike): boolean {
const outline = this.outline(shape)
const zoomLevel = this.app.zoomLevel
const offsetDist = this.app.getStrokeWidth(shape.props.size) / zoomLevel
if (shape.props.segments.length === 1 && shape.props.segments[0].points.length < 4) {
if (shape.props.segments[0].points.some((pt) => Vec2d.Dist(point, pt) < offsetDist * 1.5)) {
return true
}
}
if (this.bounds(shape).containsPoint(point)) {
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
}
}
return false
}
hitTestLineSegment(shape: TLHighlightShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
if (shape.props.segments.length === 1 && shape.props.segments[0].points.length < 4) {
const zoomLevel = this.app.zoomLevel
const offsetDist = this.app.getStrokeWidth(shape.props.size) / zoomLevel
if (
shape.props.segments[0].points.some(
(pt) => Vec2d.DistanceToLineSegment(A, B, pt) < offsetDist * 1.5
)
) {
return true
}
}
for (let i = 0; i < outline.length - 1; i++) {
const C = outline[i]
const D = outline[i + 1]
if (linesIntersect(A, B, C, D)) return true
}
return false
}
render(shape: TLHighlightShape) {
const forceSolid = useForceSolid()
const strokeWidth = this.app.getStrokeWidth(shape.props.size)
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(
{ isComplete: shape.props.isComplete, isPen: shape.props.isPen, dash: 'draw' },
sw,
showAsComplete,
forceSolid
)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, false)
: getDot(allPointsFromSegments[0], sw)
if (!forceSolid || strokePoints.length < 2) {
setStrokePointRadii(strokePoints, options)
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options)
return (
<SVGContainer id={shape.id}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={getSvgPathFromStroke(strokeOutlinePoints, true)}
strokeLinecap="round"
fill="currentColor"
/>
</SVGContainer>
)
}
return (
<SVGContainer id={shape.id}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDashoffset="0"
/>
</SVGContainer>
)
}
indicator(shape: TLHighlightShape) {
const forceSolid = useForceSolid()
const strokeWidth = this.app.getStrokeWidth(shape.props.size)
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
let sw = strokeWidth
if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
const options = getFreehandOptions(
{ dash: 'draw', isComplete: shape.props.isComplete, isPen: shape.props.isPen },
sw,
showAsComplete,
true
)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
const solidStrokePath =
strokePoints.length > 1
? getSvgPathFromStrokePoints(strokePoints, false)
: getDot(allPointsFromSegments[0], sw)
return <path d={solidStrokePath} />
}
toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors) {
const { color } = shape.props
const strokeWidth = this.app.getStrokeWidth(shape.props.size)
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight'
let sw = strokeWidth
if (!shape.props.isPen && allPointsFromSegments.length === 1) {
sw += rng(shape.id)() * (strokeWidth / 6)
}
const options = getFreehandOptions(
{ dash: 'draw', isComplete: shape.props.isComplete, isPen: shape.props.isPen },
sw,
showAsComplete,
false
)
const strokePoints = getStrokePoints(allPointsFromSegments, options)
setStrokePointRadii(strokePoints, options)
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options)
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', getSvgPathFromStroke(strokeOutlinePoints, true))
path.setAttribute('fill', colors.fill[color])
path.setAttribute('stroke-linecap', 'round')
return path
}
override onResize: OnResizeHandler<TLHighlightShape> = (shape, info) => {
const { scaleX, scaleY } = info
const newSegments: TLDrawShapeSegment[] = []
for (const segment of shape.props.segments) {
newSegments.push({
...segment,
points: segment.points.map(({ x, y, z }) => {
return {
x: scaleX * x,
y: scaleY * y,
z,
}
}),
})
}
return {
props: {
segments: newSegments,
},
}
}
expandSelectionOutlinePx(shape: TLHighlightShape): number {
return (this.app.getStrokeWidth(shape.props.size) * 1.6) / 2
}
}
function getDot(point: VecLike, sw: number) {
const r = (sw + 1) * 0.5
return `M ${point.x} ${point.y} m -${r}, 0 a ${r},${r} 0 1,0 ${r * 2},0 a ${r},${r} 0 1,0 -${
r * 2
},0`
}

View file

@ -2,6 +2,7 @@ import { TLEventHandlers } from '../types/event-types'
import { StateNode } from './StateNode'
import { TLArrowTool } from './TLArrowTool/TLArrowTool'
import { TLDrawTool } from './TLDrawTool/TLDrawTool'
import { TLHighlightTool } from './TLDrawTool/TLHighlightTool'
import { TLEraserTool } from './TLEraserTool/TLEraserTool'
import { TLFrameTool } from './TLFrameTool/TLFrameTool'
import { TLGeoTool } from './TLGeoTool/TLGeoTool'
@ -21,6 +22,7 @@ export class RootState extends StateNode {
TLHandTool,
TLEraserTool,
TLDrawTool,
TLHighlightTool,
TLTextTool,
TLLineTool,
TLArrowTool,

View file

@ -0,0 +1,18 @@
import { TLStyleType } from '@tldraw/tlschema'
import { StateNode } from '../StateNode'
import { Drawing } from './children/Drawing'
import { Idle } from './children/Idle'
export class TLHighlightTool extends StateNode {
static override id = 'highlight'
static initial = 'idle'
static children = () => [Idle, Drawing]
styles = ['color', 'opacity', 'size'] as TLStyleType[]
onExit = () => {
const drawingState = this.children!['drawing'] as Drawing
drawingState.initialShape = undefined
}
}

View file

@ -3,6 +3,7 @@ import {
createShapeId,
TLDrawShape,
TLDrawShapeSegment,
TLHighlightShape,
TLSizeType,
Vec2dModel,
} from '@tldraw/tlschema'
@ -12,16 +13,24 @@ import { uniqueId } from '../../../../utils/data'
import { TLDrawUtil } from '../../../shapeutils/TLDrawUtil/TLDrawUtil'
import { TLEventHandlers, TLPointerEventInfo } from '../../../types/event-types'
import { TLHighlightUtil } from '../../../shapeutils/TLHighlightUtil/TLHighlightUtil'
import { StateNode } from '../../StateNode'
type DrawableShape = TLDrawShape | TLHighlightShape
export class Drawing extends StateNode {
static override id = 'drawing'
info = {} as TLPointerEventInfo
initialShape?: TLDrawShape
initialShape?: DrawableShape
util = this.app.getShapeUtil(TLDrawUtil)
shapeType: 'draw' | 'highlight' = this.parent.id === 'highlight' ? 'highlight' : 'draw'
util =
this.shapeType === 'highlight'
? this.app.getShapeUtil(TLHighlightUtil)
: this.app.getShapeUtil(TLDrawUtil)
isPen = false
@ -127,7 +136,13 @@ export class Drawing extends StateNode {
this.pagePointWhereCurrentSegmentChanged = this.app.inputs.currentPagePoint.clone()
}
canClose() {
return this.shapeType !== 'highlight'
}
getIsClosed(segments: TLDrawShapeSegment[], size: TLSizeType) {
if (!this.canClose()) return false
const strokeWidth = this.app.getStrokeWidth(size)
const firstPoint = segments[0].points[0]
const lastSegment = segments[segments.length - 1]
@ -158,7 +173,7 @@ export class Drawing extends StateNode {
this.lastRecordedPoint = originPagePoint.clone()
if (this.initialShape) {
const shape = this.app.getShapeById<TLDrawShape>(this.initialShape.id)
const shape = this.app.getShapeById<DrawableShape>(this.initialShape.id)
if (shape && this.segmentMode === 'straight') {
// Connect dots
@ -204,10 +219,10 @@ export class Drawing extends StateNode {
this.app.updateShapes([
{
id: shape.id,
type: 'draw',
type: this.shapeType,
props: {
segments,
isClosed: this.getIsClosed(segments, shape.props.size),
isClosed: this.canClose() ? this.getIsClosed(segments, shape.props.size) : undefined,
},
},
])
@ -223,7 +238,7 @@ export class Drawing extends StateNode {
this.app.createShapes([
{
id,
type: 'draw',
type: this.shapeType,
x: originPagePoint.x,
y: originPagePoint.y,
props: {
@ -245,7 +260,7 @@ export class Drawing extends StateNode {
])
this.currentLineLength = 0
this.initialShape = this.app.getShapeById<TLDrawShape>(id)
this.initialShape = this.app.getShapeById<DrawableShape>(id)
}
private updateShapes() {
@ -259,7 +274,7 @@ export class Drawing extends StateNode {
props: { size },
} = initialShape
const shape = this.app.getShapeById<TLDrawShape>(id)!
const shape = this.app.getShapeById<DrawableShape>(id)!
if (!shape) return
@ -329,10 +344,10 @@ export class Drawing extends StateNode {
[
{
id,
type: 'draw',
type: this.shapeType,
props: {
segments: [...segments, newSegment],
isClosed: this.getIsClosed(segments, size),
isClosed: this.canClose() ? this.getIsClosed(segments, size) : undefined,
},
},
],
@ -386,10 +401,10 @@ export class Drawing extends StateNode {
[
{
id,
type: 'draw',
type: this.shapeType,
props: {
segments: finalSegments,
isClosed: this.getIsClosed(finalSegments, size),
isClosed: this.canClose() ? this.getIsClosed(finalSegments, size) : undefined,
},
},
],
@ -525,10 +540,10 @@ export class Drawing extends StateNode {
[
{
id,
type: 'draw',
type: this.shapeType,
props: {
segments: newSegments,
isClosed: this.getIsClosed(segments, size),
isClosed: this.canClose() ? this.getIsClosed(segments, size) : undefined,
},
},
],
@ -567,10 +582,10 @@ export class Drawing extends StateNode {
[
{
id,
type: 'draw',
type: this.shapeType,
props: {
segments: newSegments,
isClosed: this.getIsClosed(segments, size),
isClosed: this.canClose() ? this.getIsClosed(segments, size) : undefined,
},
},
],
@ -579,7 +594,7 @@ export class Drawing extends StateNode {
// Set a maximum length for the lines array; after 200 points, complete the line.
if (newPoints.length > 500) {
this.app.updateShapes([{ id, type: 'draw', props: { isComplete: true } }])
this.app.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }])
const { currentPagePoint } = this.app.inputs
@ -588,7 +603,7 @@ export class Drawing extends StateNode {
this.app.createShapes([
{
id: newShapeId,
type: 'draw',
type: this.shapeType,
x: currentPagePoint.x,
y: currentPagePoint.y,
props: {
@ -603,7 +618,7 @@ export class Drawing extends StateNode {
},
])
this.initialShape = structuredClone(this.app.getShapeById<TLDrawShape>(newShapeId)!)
this.initialShape = structuredClone(this.app.getShapeById<DrawableShape>(newShapeId)!)
this.mergeNextPoint = false
this.lastRecordedPoint = this.app.inputs.currentPagePoint.clone()
this.currentLineLength = 0

View file

@ -20,6 +20,7 @@ import { TLEmbedUtil } from '../app/shapeutils/TLEmbedUtil/TLEmbedUtil'
import { TLFrameUtil } from '../app/shapeutils/TLFrameUtil/TLFrameUtil'
import { TLGeoUtil } from '../app/shapeutils/TLGeoUtil/TLGeoUtil'
import { TLGroupUtil } from '../app/shapeutils/TLGroupUtil/TLGroupUtil'
import { TLHighlightUtil } from '../app/shapeutils/TLHighlightUtil/TLHighlightUtil'
import { TLImageUtil } from '../app/shapeutils/TLImageUtil/TLImageUtil'
import { TLLineUtil } from '../app/shapeutils/TLLineUtil/TLLineUtil'
import { TLNoteUtil } from '../app/shapeutils/TLNoteUtil/TLNoteUtil'
@ -47,6 +48,7 @@ const DEFAULT_SHAPE_UTILS: {
note: TLNoteUtil,
text: TLTextUtil,
video: TLVideoUtil,
highlight: TLHighlightUtil,
}
/** @public */

View file

@ -1,4 +1,4 @@
import { TLDrawShape } from '@tldraw/tlschema'
import { TLDrawShape, TLHighlightShape } from '@tldraw/tlschema'
import { last } from '@tldraw/utils'
import { TestApp } from '../TestApp'
@ -16,237 +16,248 @@ beforeEach(() => {
app.createShapes([])
})
describe('When drawing...', () => {
it('Creates a dot', () => {
app
.setSelectedTool('draw')
.pointerDown(60, 60)
.expectToBeIn('draw.drawing')
.pointerUp()
.expectToBeIn('draw.idle')
type DrawableShape = TLDrawShape | TLHighlightShape
expect(app.shapesArray).toHaveLength(1)
for (const toolType of ['draw', 'highlight'] as const) {
describe(`When ${toolType}ing...`, () => {
it('Creates a dot', () => {
app
.setSelectedTool(toolType)
.pointerDown(60, 60)
.expectToBeIn(`${toolType}.drawing`)
.pointerUp()
.expectToBeIn(`${toolType}.idle`)
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(1)
expect(app.shapesArray).toHaveLength(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('free')
const shape = app.shapesArray[0] as DrawableShape
expect(shape.type).toBe(toolType)
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('free')
})
it('Creates a dot when shift is held down', () => {
app
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(60, 60)
.expectToBeIn(`${toolType}.drawing`)
.pointerUp()
.expectToBeIn(`${toolType}.idle`)
expect(app.shapesArray).toHaveLength(1)
const shape = app.shapesArray[0] as DrawableShape
expect(shape.type).toBe(toolType)
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('straight')
})
it('Creates a free draw line when shift is not held', () => {
app.setSelectedTool(toolType).pointerDown(10, 10).pointerMove(20, 20)
const shape = app.shapesArray[0] as DrawableShape
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('free')
})
it('Creates a straight line when shift is held', () => {
app.setSelectedTool(toolType).keyDown('Shift').pointerDown(10, 10).pointerMove(20, 20)
const shape = app.shapesArray[0] as DrawableShape
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('straight')
const points = segment.points
expect(points.length).toBe(2)
})
it('Switches between segment types when shift is pressed / released (starting with shift up)', () => {
app
.setSelectedTool(toolType)
.pointerDown(10, 10)
.pointerMove(20, 20)
.keyDown('Shift')
.pointerMove(30, 30)
.keyUp('Shift')
.pointerMove(40, 40)
.pointerUp()
const shape = app.shapesArray[0] as DrawableShape
expect(shape.props.segments.length).toBe(3)
expect(shape.props.segments[0].type).toBe('free')
expect(shape.props.segments[1].type).toBe('straight')
expect(shape.props.segments[2].type).toBe('free')
})
it('Switches between segment types when shift is pressed / released (starting with shift down)', () => {
app
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(10, 10)
.pointerMove(20, 20)
.keyUp('Shift')
.pointerMove(30, 30)
.keyDown('Shift')
.pointerMove(40, 40)
.pointerUp()
const shape = app.shapesArray[0] as DrawableShape
expect(shape.props.segments.length).toBe(3)
expect(shape.props.segments[0].type).toBe('straight')
expect(shape.props.segments[1].type).toBe('free')
expect(shape.props.segments[2].type).toBe('straight')
})
it('Extends previously drawn line when shift is held', () => {
app
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(10, 10)
.pointerUp()
.pointerDown(20, 20)
const shape1 = app.shapesArray[0] as DrawableShape
expect(shape1.props.segments.length).toBe(2)
expect(shape1.props.segments[0].type).toBe('straight')
expect(shape1.props.segments[1].type).toBe('straight')
app.pointerUp().pointerDown(30, 30).pointerUp()
const shape2 = app.shapesArray[0] as DrawableShape
expect(shape2.props.segments.length).toBe(3)
expect(shape2.props.segments[2].type).toBe('straight')
})
it('Does not extends previously drawn line after switching to another tool', () => {
app
.setSelectedTool(toolType)
.pointerDown(10, 10)
.pointerUp()
.setSelectedTool('select')
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(20, 20)
.pointerMove(30, 30)
expect(app.shapesArray).toHaveLength(2)
const shape1 = app.shapesArray[0] as DrawableShape
expect(shape1.props.segments.length).toBe(1)
expect(shape1.props.segments[0].type).toBe('free')
const shape2 = app.shapesArray[1] as DrawableShape
expect(shape2.props.segments.length).toBe(1)
expect(shape2.props.segments[0].type).toBe('straight')
})
it('Snaps to 15 degree angle when shift is held', () => {
const magnitude = 10
const angle = (17 * Math.PI) / 180
const x = magnitude * Math.cos(angle)
const y = magnitude * Math.sin(angle)
const snappedAngle = (15 * Math.PI) / 180
const snappedX = magnitude * Math.cos(snappedAngle)
const snappedY = magnitude * Math.sin(snappedAngle)
app.setSelectedTool(toolType).keyDown('Shift').pointerDown(0, 0).pointerMove(x, y)
const shape = app.shapesArray[0] as DrawableShape
const segment = shape.props.segments[0]
expect(segment.points[1].x).toBeCloseTo(snappedX)
expect(segment.points[1].y).toBeCloseTo(snappedY)
})
it('Doesnt snap to 15 degree angle when cmd is held', () => {
const magnitude = 10
const angle = (17 * Math.PI) / 180
const x = magnitude * Math.cos(angle)
const y = magnitude * Math.sin(angle)
app.setSelectedTool(toolType).keyDown('Meta').pointerDown(0, 0).pointerMove(x, y)
const shape = app.shapesArray[0] as DrawableShape
const segment = shape.props.segments[0]
expect(segment.points[1].x).toBeCloseTo(x)
expect(segment.points[1].y).toBeCloseTo(y)
})
it('Snaps to start or end of straight segments in self when shift + cmd is held', () => {
app
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(0, 0)
.pointerUp()
.pointerDown(0, 10)
.pointerUp()
.pointerDown(10, 0)
.pointerUp()
.pointerDown(10, 0)
.pointerMove(1, 0)
const shape1 = app.shapesArray[0] as DrawableShape
const segment1 = last(shape1.props.segments)!
const point1 = last(segment1.points)!
expect(point1.x).toBe(1)
app.keyDown('Meta')
const shape2 = app.shapesArray[0] as DrawableShape
const segment2 = last(shape2.props.segments)!
const point2 = last(segment2.points)!
expect(point2.x).toBe(0)
})
it('Snaps to position along straight segments in self when shift + cmd is held', () => {
app
.setSelectedTool(toolType)
.keyDown('Shift')
.pointerDown(0, 0)
.pointerUp()
.pointerDown(0, 10)
.pointerUp()
.pointerDown(10, 5)
.pointerUp()
.pointerDown(10, 5)
.pointerMove(1, 5)
const shape1 = app.shapesArray[0] as DrawableShape
const segment1 = last(shape1.props.segments)!
const point1 = last(segment1.points)!
expect(point1.x).toBe(1)
app.keyDown('Meta')
const shape2 = app.shapesArray[0] as DrawableShape
const segment2 = last(shape2.props.segments)!
const point2 = last(segment2.points)!
expect(point2.x).toBe(0)
})
it('Deletes very short lines on interrupt', () => {
app.setSelectedTool(toolType).pointerDown(0, 0).pointerMove(0.1, 0.1).interrupt()
expect(app.shapesArray).toHaveLength(0)
})
it('Does not delete longer lines on interrupt', () => {
app.setSelectedTool(toolType).pointerDown(0, 0).pointerMove(5, 5).interrupt()
expect(app.shapesArray).toHaveLength(1)
})
it('Completes on cancel', () => {
app.setSelectedTool(toolType).pointerDown(0, 0).pointerMove(5, 5).cancel()
expect(app.shapesArray).toHaveLength(1)
const shape = app.shapesArray[0] as DrawableShape
expect(shape.props.segments.length).toBe(1)
})
})
it('Creates a dot when shift is held down', () => {
app
.setSelectedTool('draw')
.keyDown('Shift')
.pointerDown(60, 60)
.expectToBeIn('draw.drawing')
.pointerUp()
.expectToBeIn('draw.idle')
expect(app.shapesArray).toHaveLength(1)
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('straight')
})
it('Creates a free draw line when shift is not held', () => {
app.setSelectedTool('draw').pointerDown(10, 10).pointerMove(20, 20)
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('free')
})
it('Creates a straight line when shift is held', () => {
app.setSelectedTool('draw').keyDown('Shift').pointerDown(10, 10).pointerMove(20, 20)
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(1)
const segment = shape.props.segments[0]
expect(segment.type).toBe('straight')
const points = segment.points
expect(points.length).toBe(2)
})
it('Switches between segment types when shift is pressed / released (starting with shift up)', () => {
app
.setSelectedTool('draw')
.pointerDown(10, 10)
.pointerMove(20, 20)
.keyDown('Shift')
.pointerMove(30, 30)
.keyUp('Shift')
.pointerMove(40, 40)
.pointerUp()
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(3)
expect(shape.props.segments[0].type).toBe('free')
expect(shape.props.segments[1].type).toBe('straight')
expect(shape.props.segments[2].type).toBe('free')
})
it('Switches between segment types when shift is pressed / released (starting with shift down)', () => {
app
.setSelectedTool('draw')
.keyDown('Shift')
.pointerDown(10, 10)
.pointerMove(20, 20)
.keyUp('Shift')
.pointerMove(30, 30)
.keyDown('Shift')
.pointerMove(40, 40)
.pointerUp()
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(3)
expect(shape.props.segments[0].type).toBe('straight')
expect(shape.props.segments[1].type).toBe('free')
expect(shape.props.segments[2].type).toBe('straight')
})
it('Extends previously drawn line when shift is held', () => {
app.setSelectedTool('draw').keyDown('Shift').pointerDown(10, 10).pointerUp().pointerDown(20, 20)
const shape1 = app.shapesArray[0] as TLDrawShape
expect(shape1.props.segments.length).toBe(2)
expect(shape1.props.segments[0].type).toBe('straight')
expect(shape1.props.segments[1].type).toBe('straight')
app.pointerUp().pointerDown(30, 30).pointerUp()
const shape2 = app.shapesArray[0] as TLDrawShape
expect(shape2.props.segments.length).toBe(3)
expect(shape2.props.segments[2].type).toBe('straight')
})
it('Does not extends previously drawn line after switching to another tool', () => {
app
.setSelectedTool('draw')
.pointerDown(10, 10)
.pointerUp()
.setSelectedTool('select')
.setSelectedTool('draw')
.keyDown('Shift')
.pointerDown(20, 20)
.pointerMove(30, 30)
expect(app.shapesArray).toHaveLength(2)
const shape1 = app.shapesArray[0] as TLDrawShape
expect(shape1.props.segments.length).toBe(1)
expect(shape1.props.segments[0].type).toBe('free')
const shape2 = app.shapesArray[1] as TLDrawShape
expect(shape2.props.segments.length).toBe(1)
expect(shape2.props.segments[0].type).toBe('straight')
})
it('Snaps to 15 degree angle when shift is held', () => {
const magnitude = 10
const angle = (17 * Math.PI) / 180
const x = magnitude * Math.cos(angle)
const y = magnitude * Math.sin(angle)
const snappedAngle = (15 * Math.PI) / 180
const snappedX = magnitude * Math.cos(snappedAngle)
const snappedY = magnitude * Math.sin(snappedAngle)
app.setSelectedTool('draw').keyDown('Shift').pointerDown(0, 0).pointerMove(x, y)
const shape = app.shapesArray[0] as TLDrawShape
const segment = shape.props.segments[0]
expect(segment.points[1].x).toBeCloseTo(snappedX)
expect(segment.points[1].y).toBeCloseTo(snappedY)
})
it('Doesnt snap to 15 degree angle when cmd is held', () => {
const magnitude = 10
const angle = (17 * Math.PI) / 180
const x = magnitude * Math.cos(angle)
const y = magnitude * Math.sin(angle)
app.setSelectedTool('draw').keyDown('Meta').pointerDown(0, 0).pointerMove(x, y)
const shape = app.shapesArray[0] as TLDrawShape
const segment = shape.props.segments[0]
expect(segment.points[1].x).toBeCloseTo(x)
expect(segment.points[1].y).toBeCloseTo(y)
})
it('Snaps to start or end of straight segments in self when shift + cmd is held', () => {
app
.setSelectedTool('draw')
.keyDown('Shift')
.pointerDown(0, 0)
.pointerUp()
.pointerDown(0, 10)
.pointerUp()
.pointerDown(10, 0)
.pointerUp()
.pointerDown(10, 0)
.pointerMove(1, 0)
const shape1 = app.shapesArray[0] as TLDrawShape
const segment1 = last(shape1.props.segments)!
const point1 = last(segment1.points)!
expect(point1.x).toBe(1)
app.keyDown('Meta')
const shape2 = app.shapesArray[0] as TLDrawShape
const segment2 = last(shape2.props.segments)!
const point2 = last(segment2.points)!
expect(point2.x).toBe(0)
})
it('Snaps to position along straight segments in self when shift + cmd is held', () => {
app
.setSelectedTool('draw')
.keyDown('Shift')
.pointerDown(0, 0)
.pointerUp()
.pointerDown(0, 10)
.pointerUp()
.pointerDown(10, 5)
.pointerUp()
.pointerDown(10, 5)
.pointerMove(1, 5)
const shape1 = app.shapesArray[0] as TLDrawShape
const segment1 = last(shape1.props.segments)!
const point1 = last(segment1.points)!
expect(point1.x).toBe(1)
app.keyDown('Meta')
const shape2 = app.shapesArray[0] as TLDrawShape
const segment2 = last(shape2.props.segments)!
const point2 = last(segment2.points)!
expect(point2.x).toBe(0)
})
it('Deletes very short lines on interrupt', () => {
app.setSelectedTool('draw').pointerDown(0, 0).pointerMove(0.1, 0.1).interrupt()
expect(app.shapesArray).toHaveLength(0)
})
it('Does not delete longer lines on interrupt', () => {
app.setSelectedTool('draw').pointerDown(0, 0).pointerMove(5, 5).interrupt()
expect(app.shapesArray).toHaveLength(1)
})
it('Completes on cancel', () => {
app.setSelectedTool('draw').pointerDown(0, 0).pointerMove(5, 5).cancel()
expect(app.shapesArray).toHaveLength(1)
const shape = app.shapesArray[0] as TLDrawShape
expect(shape.props.segments.length).toBe(1)
})
})
}

View file

@ -11,6 +11,7 @@ export const featureFlags = {
// todo: remove this. it's not used, but we only have one feature flag and i
// wanted an example :(
peopleMenu: createFeatureFlag('peopleMenu'),
highlighterTool: createFeatureFlag('highlighterTool'),
} satisfies Record<string, DebugFlag<boolean>>
/** @internal */

View file

@ -375,6 +375,12 @@ export const groupShapeTypeValidator: T.Validator<TLGroupShape>;
// @public (undocumented)
export const handleTypeValidator: T.Validator<TLHandle>;
// @public (undocumented)
export const highlightShapeMigrations: Migrations;
// @public (undocumented)
export const highlightShapeTypeValidator: T.Validator<TLHighlightShape>;
// @public (undocumented)
export const iconShapeTypeMigrations: Migrations;
@ -760,7 +766,7 @@ export interface TLDashStyle extends TLBaseStyle {
export type TLDashType = SetValue<typeof TL_DASH_TYPES>;
// @public
export type TLDefaultShape = TLArrowShape | TLBookmarkShape | TLDrawShape | TLEmbedShape | TLFrameShape | TLGeoShape | TLGroupShape | TLIconShape | TLImageShape | TLLineShape | TLNoteShape | TLTextShape | TLVideoShape;
export type TLDefaultShape = TLArrowShape | TLBookmarkShape | TLDrawShape | TLEmbedShape | TLFrameShape | TLGeoShape | TLGroupShape | TLHighlightShape | TLIconShape | TLImageShape | TLLineShape | TLNoteShape | TLTextShape | TLVideoShape;
// @public
export interface TLDocument extends BaseRecord<'document', ID<TLDocument>> {
@ -932,6 +938,19 @@ export interface TLHandlePartial {
// @public (undocumented)
export type TLHandleType = SetValue<typeof TL_HANDLE_TYPES>;
// @public (undocumented)
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>;
// @public (undocumented)
export type TLHighlightShapeProps = {
color: TLColorType;
size: TLSizeType;
opacity: TLOpacityType;
segments: TLDrawShapeSegment[];
isComplete: boolean;
isPen: boolean;
};
// @public (undocumented)
export type TLIconShape = TLBaseShape<'icon', TLIconShapeProps>;

View file

@ -20,6 +20,7 @@ import { embedShapeTypeMigrations, embedShapeTypeValidator } from './shapes/TLEm
import { frameShapeTypeMigrations, frameShapeTypeValidator } from './shapes/TLFrameShape'
import { geoShapeTypeMigrations, geoShapeTypeValidator } from './shapes/TLGeoShape'
import { groupShapeTypeMigrations, groupShapeTypeValidator } from './shapes/TLGroupShape'
import { highlightShapeMigrations, highlightShapeTypeValidator } from './shapes/TLHighlightShape'
import { imageShapeTypeMigrations, imageShapeTypeValidator } from './shapes/TLImageShape'
import { lineShapeTypeMigrations, lineShapeTypeValidator } from './shapes/TLLineShape'
import { noteShapeTypeMigrations, noteShapeTypeValidator } from './shapes/TLNoteShape'
@ -45,6 +46,7 @@ const DEFAULT_SHAPES: { [K in TLShape['type']]: DefaultShapeInfo<Extract<TLShape
note: { migrations: noteShapeTypeMigrations, validator: noteShapeTypeValidator },
text: { migrations: textShapeTypeMigrations, validator: textShapeTypeValidator },
video: { migrations: videoShapeTypeMigrations, validator: videoShapeTypeValidator },
highlight: { migrations: highlightShapeMigrations, validator: highlightShapeTypeValidator },
}
type CustomShapeInfo<T extends TLUnknownShape> = {

View file

@ -156,6 +156,12 @@ export {
type TLGroupShape,
type TLGroupShapeProps,
} from './shapes/TLGroupShape'
export {
highlightShapeMigrations,
highlightShapeTypeValidator,
type TLHighlightShape,
type TLHighlightShapeProps,
} from './shapes/TLHighlightShape'
export {
iconShapeTypeMigrations,
iconShapeTypeValidator,

View file

@ -8,6 +8,7 @@ import { TLEmbedShape } from '../shapes/TLEmbedShape'
import { TLFrameShape } from '../shapes/TLFrameShape'
import { TLGeoShape } from '../shapes/TLGeoShape'
import { TLGroupShape } from '../shapes/TLGroupShape'
import { TLHighlightShape } from '../shapes/TLHighlightShape'
import { TLIconShape } from '../shapes/TLIconShape'
import { TLImageShape } from '../shapes/TLImageShape'
import { TLLineShape } from '../shapes/TLLineShape'
@ -35,6 +36,7 @@ export type TLDefaultShape =
| TLTextShape
| TLVideoShape
| TLIconShape
| TLHighlightShape
/**
* A type for a shape that is available in the editor but whose type is

View file

@ -21,6 +21,11 @@ export type TLDrawShapeSegment = {
points: Vec2dModel[]
}
export const drawShapeSegmentValidator: T.Validator<TLDrawShapeSegment> = T.object({
type: T.setEnum(TL_DRAW_SHAPE_SEGMENT_TYPE),
points: T.arrayOf(T.point),
})
/** @public */
export type TLDrawShapeProps = {
color: TLColorType
@ -46,12 +51,7 @@ export const drawShapeTypeValidator: T.Validator<TLDrawShape> = createShapeValid
dash: dashValidator,
size: sizeValidator,
opacity: opacityValidator,
segments: T.arrayOf(
T.object({
type: T.setEnum(TL_DRAW_SHAPE_SEGMENT_TYPE),
points: T.arrayOf(T.point),
})
),
segments: T.arrayOf(drawShapeSegmentValidator),
isComplete: T.boolean,
isClosed: T.boolean,
isPen: T.boolean,

View file

@ -0,0 +1,37 @@
import { defineMigrations } from '@tldraw/tlstore'
import { T } from '@tldraw/tlvalidate'
import { TLColorType, TLOpacityType, TLSizeType } from '../style-types'
import { colorValidator, opacityValidator, sizeValidator } from '../validation'
import { TLDrawShapeSegment, drawShapeSegmentValidator } from './TLDrawShape'
import { TLBaseShape, createShapeValidator } from './shape-validation'
/** @public */
export type TLHighlightShapeProps = {
color: TLColorType
size: TLSizeType
opacity: TLOpacityType
segments: TLDrawShapeSegment[]
isComplete: boolean
isPen: boolean
}
/** @public */
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>
// --- VALIDATION ---
/** @public */
export const highlightShapeTypeValidator: T.Validator<TLHighlightShape> = createShapeValidator(
'highlight',
T.object({
color: colorValidator,
size: sizeValidator,
opacity: opacityValidator,
segments: T.arrayOf(drawShapeSegmentValidator),
isComplete: T.boolean,
isPen: T.boolean,
})
)
// --- MIGRATIONS ---
/** @public */
export const highlightShapeMigrations = defineMigrations({})

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,6 @@
import { App, useApp } from '@tldraw/editor'
import { App, featureFlags, useApp } from '@tldraw/editor'
import React from 'react'
import { useValue } from 'signia-react'
import { ToolItem, ToolsContextType, useTools } from './useTools'
/** @public */
@ -41,6 +42,7 @@ export function ToolbarSchemaProvider({ overrides, children }: ToolbarSchemaProv
const app = useApp()
const tools = useTools()
const highlighterEnabled = useValue(featureFlags.highlighterTool)
const toolbarSchema = React.useMemo<ToolbarSchemaContextType>(() => {
const schema: ToolbarSchemaContextType = [
@ -74,12 +76,16 @@ export function ToolbarSchemaProvider({ overrides, children }: ToolbarSchemaProv
toolbarItem(tools.laser),
]
if (highlighterEnabled) {
schema.push(toolbarItem(tools.highlight))
}
if (overrides) {
return overrides(app, schema, { tools })
}
return schema
}, [app, overrides, tools])
}, [app, highlighterEnabled, overrides, tools])
return (
<ToolbarSchemaContext.Provider value={toolbarSchema}>{children}</ToolbarSchemaContext.Provider>

View file

@ -1,5 +1,6 @@
import { App, TL_GEO_TYPES, useApp } from '@tldraw/editor'
import { App, TL_GEO_TYPES, featureFlags, useApp } from '@tldraw/editor'
import * as React from 'react'
import { useValue } from 'signia-react'
import { EmbedDialog } from '../components/EmbedDialog'
import { TLUiIconType } from '../icon-types'
import { useDialogs } from './useDialogsProvider'
@ -45,8 +46,10 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
const { addDialog } = useDialogs()
const insertMedia = useInsertMedia()
const highlighterEnabled = useValue(featureFlags.highlighterTool)
const tools = React.useMemo<ToolsContextType>(() => {
const tools = makeTools([
const toolsArray: ToolItem[] = [
{
id: 'select',
label: 'tool.select',
@ -198,14 +201,31 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
trackEvent('select-tool', { source, id: 'embed' })
},
},
])
]
if (highlighterEnabled) {
toolsArray.push({
id: 'highlight',
label: 'tool.highlight',
readonlyOk: true,
icon: 'tool-highlight',
// TODO: pick a better shortcut
kbd: 'i',
onSelect(source) {
app.setSelectedTool('highlight')
trackEvent('select-tool', { source, id: 'highlight' })
},
})
}
const tools = makeTools(toolsArray)
if (overrides) {
return overrides(app, tools, { insertMedia })
}
return tools
}, [app, trackEvent, overrides, insertMedia, addDialog])
}, [highlighterEnabled, overrides, app, trackEvent, insertMedia, addDialog])
return <ToolsContext.Provider value={tools}>{children}</ToolsContext.Provider>
}

View file

@ -184,6 +184,7 @@ export type TLTranslationKey =
| 'tool.diamond'
| 'tool.ellipse'
| 'tool.hexagon'
| 'tool.highlight'
| 'tool.line'
| 'tool.octagon'
| 'tool.oval'

View file

@ -184,6 +184,7 @@ export const DEFAULT_TRANSLATION = {
'tool.diamond': 'Diamond',
'tool.ellipse': 'Ellipse',
'tool.hexagon': 'Hexagon',
'tool.highlight': 'Highlight',
'tool.line': 'Line',
'tool.octagon': 'Octagon',
'tool.oval': 'Oval',

View file

@ -141,6 +141,7 @@ export type TLUiIconType =
| 'tool-eraser'
| 'tool-frame'
| 'tool-hand'
| 'tool-highlight'
| 'tool-highlighter'
| 'tool-laser'
| 'tool-line'
@ -305,6 +306,7 @@ export const TLUiIconTypes = [
'tool-eraser',
'tool-frame',
'tool-hand',
'tool-highlight',
'tool-highlighter',
'tool-laser',
'tool-line',