
Currently, the highlighter shape uses a single 0-width line for its geometry, same as the draw tool. For the draw tool this works ok - the visual line is thin enough that unless you zoom right in, it's hard to find areas where the hover should trigger but isn't. As the highlighter tool is much thicker though, it's relatively easy to find those areas. The fix is for the geometry to represent the line including its thick stroke, instead of at 0-width. There are two possible approaches here: 1. Update the polyline geometry to allow passing a stroke width. 2. Instead of a polyline, make the highlighter shape be a polygon that traces _around_ the stroke 1 is the more accurate approach, but is hard to fit into our geometry system. Our geometry is based around two primitives: `getVertices` which returns an array of points around the shape, and `nearestPoint` which returns the nearest point on the geometry to a vector we pass in. We can account for a stroke in `nearestPoint` pretty easily, including it in `getVertices` is hard - we'd have to expand the vertices and handle line join/caps etc. Just making the change in `nearestPoint` does fix the issue here, but i'm not sure about the knock-on effect elsewhere and don't really want to introduce 1-off hacks into the core geometry system. 2 actually means addressing the same hard problem around outlining strokes as 1, but it lets us do it in a more tightly-scoped one-off change just to the highlighter shape, instead of trying to come up with a generic solution for the whole geometry system. This is the approach I've taken in this diff. We outline the stroke using perfect-freehand, which works pretty well but produces inaccurate results at edge-cases, particularly when a line rapidly changes direction:  I think that given this is scoped to just the highlighter shape and is imo an improvement over the stroke issue from before, it's a reasonable solution for now. If we want to in the future we could implement real non-freehand-based outlining. ### Change Type - [x] `patch` — Bug fix ### Test Plan 1. Create a highlight shape 2. Zoom in 3. Make sure you can interact with the shape at its edges instead of right in the center
213 lines
6 KiB
TypeScript
213 lines
6 KiB
TypeScript
import { Atom, atom, react } from '@tldraw/state'
|
|
|
|
// --- 1. DEFINE ---
|
|
//
|
|
// Define your debug values and feature flags here. Use `createDebugValue` to
|
|
// create an arbitrary value with defaults for production, staging, and
|
|
// development. Use `createFeatureFlag` to create a boolean flag which will be
|
|
// `true` by default in development and staging, and `false` in production.
|
|
/** @internal */
|
|
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', { all: true }),
|
|
} satisfies Record<string, DebugFlag<boolean>>
|
|
|
|
/** @internal */
|
|
export const debugFlags = {
|
|
// --- DEBUG VALUES ---
|
|
preventDefaultLogging: createDebugValue('preventDefaultLogging', {
|
|
defaults: { all: false },
|
|
}),
|
|
pointerCaptureLogging: createDebugValue('pointerCaptureLogging', {
|
|
defaults: { all: false },
|
|
}),
|
|
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', {
|
|
defaults: { all: false },
|
|
}),
|
|
pointerCaptureTrackingObject: createDebugValue(
|
|
'pointerCaptureTrackingObject',
|
|
// ideally we wouldn't store this mutable value in an atom but it's not
|
|
// a big deal for debug values
|
|
{
|
|
defaults: { all: new Map<Element, number>() },
|
|
shouldStoreForSession: false,
|
|
}
|
|
),
|
|
elementRemovalLogging: createDebugValue('elementRemovalLogging', {
|
|
defaults: { all: false },
|
|
}),
|
|
debugSvg: createDebugValue('debugSvg', {
|
|
defaults: { all: false },
|
|
}),
|
|
throwToBlob: createDebugValue('throwToBlob', {
|
|
defaults: { all: false },
|
|
}),
|
|
logMessages: createDebugValue('uiLog', { defaults: { all: [] } }),
|
|
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', {
|
|
defaults: { all: false },
|
|
}),
|
|
debugCursors: createDebugValue('debugCursors', {
|
|
defaults: { all: false },
|
|
}),
|
|
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
|
|
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
tldrawLog: (message: any) => void
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.tldrawLog = (message: any) => {
|
|
debugFlags.logMessages.set(debugFlags.logMessages.value.concat(message))
|
|
}
|
|
}
|
|
|
|
// --- 2. USE ---
|
|
// In normal code, read from debug flags directly by calling .value on them:
|
|
// if (debugFlags.preventDefaultLogging.value) { ... }
|
|
//
|
|
// In react, wrap your reads in `useValue` (or your component in `track`)
|
|
// so they react to changes:
|
|
// const shouldLog = useValue(debugFlags.preventDefaultLogging)
|
|
|
|
// --- 3. GET FUNKY ---
|
|
// If you need to do fun stuff like monkey-patching in response to flag changes,
|
|
// add that here. Make sure you wrap your code in `react` so it runs
|
|
// automatically when values change!
|
|
|
|
if (typeof Element !== 'undefined') {
|
|
const nativeElementRemoveChild = Element.prototype.removeChild
|
|
react('element removal logging', () => {
|
|
if (debugFlags.elementRemovalLogging.value) {
|
|
Element.prototype.removeChild = function <T extends Node>(this: any, child: Node): T {
|
|
console.warn('[tldraw] removing child:', child)
|
|
return nativeElementRemoveChild.call(this, child) as T
|
|
}
|
|
} else {
|
|
Element.prototype.removeChild = nativeElementRemoveChild
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- IMPLEMENTATION ---
|
|
// you probably don't need to read this if you're just using the debug values system
|
|
function createDebugValue<T>(
|
|
name: string,
|
|
{
|
|
defaults,
|
|
shouldStoreForSession = true,
|
|
}: { defaults: Defaults<T>; shouldStoreForSession?: boolean }
|
|
) {
|
|
return createDebugValueBase({
|
|
name,
|
|
defaults,
|
|
shouldStoreForSession,
|
|
})
|
|
}
|
|
function createFeatureFlag(
|
|
name: string,
|
|
defaults: Defaults<boolean> = { all: true, production: false }
|
|
) {
|
|
return createDebugValueBase({
|
|
name,
|
|
defaults,
|
|
shouldStoreForSession: true,
|
|
})
|
|
}
|
|
|
|
function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
|
|
const defaultValue = getDefaultValue(def)
|
|
const storedValue = def.shouldStoreForSession
|
|
? (getStoredInitialValue(def.name) as T | null)
|
|
: null
|
|
const valueAtom = atom(`debug:${def.name}`, storedValue ?? defaultValue)
|
|
|
|
if (typeof window !== 'undefined') {
|
|
if (def.shouldStoreForSession) {
|
|
react(`debug:${def.name}`, () => {
|
|
const currentValue = valueAtom.value
|
|
try {
|
|
if (currentValue === defaultValue) {
|
|
window.sessionStorage.removeItem(`tldraw_debug:${def.name}`)
|
|
} else {
|
|
window.sessionStorage.setItem(`tldraw_debug:${def.name}`, JSON.stringify(currentValue))
|
|
}
|
|
} catch {
|
|
// not a big deal
|
|
}
|
|
})
|
|
}
|
|
|
|
Object.defineProperty(window, `tldraw${def.name.replace(/^[a-z]/, (l) => l.toUpperCase())}`, {
|
|
get() {
|
|
return valueAtom.value
|
|
},
|
|
set(newValue) {
|
|
valueAtom.set(newValue)
|
|
},
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
return Object.assign(valueAtom, def)
|
|
}
|
|
|
|
function getStoredInitialValue(name: string) {
|
|
try {
|
|
return JSON.parse(window?.sessionStorage.getItem(`tldraw_debug:${name}`) ?? 'null')
|
|
} catch (err) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// process.env might not be defined, but we can't access it using optional
|
|
// chaining because some bundlers search for `process.env.SOMETHING` as a string
|
|
// and replace it with its value.
|
|
function readEnv(fn: () => string | undefined) {
|
|
try {
|
|
return fn()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getDefaultValue<T>(def: DebugFlagDef<T>): T {
|
|
const env =
|
|
readEnv(() => process.env.TLDRAW_ENV) ??
|
|
readEnv(() => process.env.VERCEL_PUBLIC_TLDRAW_ENV) ??
|
|
readEnv(() => process.env.NEXT_PUBLIC_TLDRAW_ENV) ??
|
|
// default to production because if we don't have one of these, this is probably a library use
|
|
'production'
|
|
|
|
switch (env) {
|
|
case 'production':
|
|
return def.defaults.production ?? def.defaults.all
|
|
case 'preview':
|
|
case 'staging':
|
|
return def.defaults.staging ?? def.defaults.all
|
|
default:
|
|
return def.defaults.development ?? def.defaults.all
|
|
}
|
|
}
|
|
|
|
interface Defaults<T> {
|
|
development?: T
|
|
staging?: T
|
|
production?: T
|
|
all: T
|
|
}
|
|
|
|
/** @internal */
|
|
export interface DebugFlagDef<T> {
|
|
name: string
|
|
defaults: Defaults<T>
|
|
shouldStoreForSession: boolean
|
|
}
|
|
|
|
/** @internal */
|
|
export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>
|