[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!  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:
parent
2992ad85d9
commit
674a829d1f
33 changed files with 732 additions and 275 deletions
4
assets/icons/icon/tool-highlight.svg
Normal file
4
assets/icons/icon/tool-highlight.svg
Normal 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 |
|
@ -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 |
1
packages/assets/imports.d.ts
vendored
1
packages/assets/imports.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
1
packages/assets/urls.d.ts
vendored
1
packages/assets/urls.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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`
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
37
packages/tlschema/src/shapes/TLHighlightShape.ts
Normal file
37
packages/tlschema/src/shapes/TLHighlightShape.ts
Normal 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
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -184,6 +184,7 @@ export type TLTranslationKey =
|
|||
| 'tool.diamond'
|
||||
| 'tool.ellipse'
|
||||
| 'tool.hexagon'
|
||||
| 'tool.highlight'
|
||||
| 'tool.line'
|
||||
| 'tool.octagon'
|
||||
| 'tool.oval'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue