tldraw/packages/editor/src/lib/utils/debug-flags.ts
alex 79f46da199
expanded highlighter geometry (#1929)
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:

![Kapture 2023-09-19 at 13 45
01](https://github.com/tldraw/tldraw/assets/1489520/1593ac5c-e7db-4360-b97d-ba66cdfb5498)

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
2023-09-26 11:21:37 +00:00

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>