[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:
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…
Reference in a new issue