diff --git a/apps/dotcom/client/package.json b/apps/dotcom/client/package.json index 113d6742a9c7..79a3fb49f03b 100644 --- a/apps/dotcom/client/package.json +++ b/apps/dotcom/client/package.json @@ -11,7 +11,7 @@ "defaults" ], "scripts": { - "dev": "./wait-for-postgres.sh && yarn run -T tsx scripts/dev-app.ts", + "dev": "../wait-for-migrations.sh && yarn run -T tsx scripts/dev-app.ts", "build": "yarn run -T tsx scripts/build.ts", "build-i18n": "yarn i18n:extract && yarn i18n:compile", "start": "VITE_PREVIEW=1 yarn run -T tsx scripts/dev-app.ts", diff --git a/apps/dotcom/client/src/tla/app/TldrawApp.ts b/apps/dotcom/client/src/tla/app/TldrawApp.ts index 002594d60bd9..d1759272289d 100644 --- a/apps/dotcom/client/src/tla/app/TldrawApp.ts +++ b/apps/dotcom/client/src/tla/app/TldrawApp.ts @@ -79,11 +79,11 @@ import { createIntl, defineMessages, setupCreateIntl } from '../utils/i18n' import { updateLocalSessionState } from '../utils/local-session-state' import { Zero as ZeroPolyfill } from './zero-polyfill' -export interface DragGroupOperation { +interface DragGroupOperation { reorder?: DragReorderOperation } -export type DragState = +type DragState = | null | { type: 'file' diff --git a/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarInlineInput.tsx b/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarInlineInput.tsx index 58669a91ac27..18dcadd7621a 100644 --- a/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarInlineInput.tsx +++ b/apps/dotcom/client/src/tla/components/TlaSidebar/components/TlaSidebarInlineInput.tsx @@ -5,7 +5,7 @@ import { useHasFlag } from '../../../hooks/useHasFlag' import { pinIcon } from './pinIcon' import styles from '../sidebar.module.css' -export interface TlaSidebarInlineInputProps { +interface TlaSidebarInlineInputProps { defaultValue?: string placeholder?: string onComplete(value: string): void diff --git a/apps/dotcom/client/src/tla/components/tla-menu/tla-menu.tsx b/apps/dotcom/client/src/tla/components/tla-menu/tla-menu.tsx index aae0b43537a2..96d8e22f68af 100644 --- a/apps/dotcom/client/src/tla/components/tla-menu/tla-menu.tsx +++ b/apps/dotcom/client/src/tla/components/tla-menu/tla-menu.tsx @@ -194,7 +194,7 @@ export function TlaMenuSelect({ /* --------------------- Switch --------------------- */ -export interface TlaMenuSwitchProps extends Omit, 'onChange'> { +interface TlaMenuSwitchProps extends Omit, 'onChange'> { id: string checked: boolean onChange?(checked: boolean): void diff --git a/apps/dotcom/client/src/tla/hooks/useDragTracking.ts b/apps/dotcom/client/src/tla/hooks/useDragTracking.ts index 7aacc1c939b4..39cdad44b8d9 100644 --- a/apps/dotcom/client/src/tla/hooks/useDragTracking.ts +++ b/apps/dotcom/client/src/tla/hooks/useDragTracking.ts @@ -6,12 +6,6 @@ import { Vec } from 'tldraw' import { TldrawApp } from '../app/TldrawApp' import { useApp } from './useAppState' -export interface DropTarget { - id: string - element: HTMLElement - rect: DOMRect -} - function detectDragOperations( elements: DragElements, mousePosition: { x: number; y: number }, diff --git a/apps/dotcom/client/src/tla/hooks/useUser.tsx b/apps/dotcom/client/src/tla/hooks/useUser.tsx index 9d3fba1b5455..d0d8be2661c1 100644 --- a/apps/dotcom/client/src/tla/hooks/useUser.tsx +++ b/apps/dotcom/client/src/tla/hooks/useUser.tsx @@ -4,7 +4,7 @@ import { ReactNode, createContext, useContext, useMemo } from 'react' import { DefaultSpinner, LoadingScreen, assert, useShallowObjectIdentity } from 'tldraw' import { useMaybeApp } from './useAppState' -export interface TldrawUser { +interface TldrawUser { id: string clerkUser: UserResource isTldraw: boolean diff --git a/apps/dotcom/client/src/tla/themes/ui-themes.ts b/apps/dotcom/client/src/tla/themes/ui-themes.ts index 542a93be70cd..77689f5ba277 100644 --- a/apps/dotcom/client/src/tla/themes/ui-themes.ts +++ b/apps/dotcom/client/src/tla/themes/ui-themes.ts @@ -11,7 +11,7 @@ export interface UIThemeVariant { tla: Record } -export interface UITheme { +interface UITheme { id: string name: string lightBackground: string diff --git a/apps/dotcom/client/src/utils/analytics.tsx b/apps/dotcom/client/src/utils/analytics.tsx index 84868a392f33..f3b646d94148 100644 --- a/apps/dotcom/client/src/utils/analytics.tsx +++ b/apps/dotcom/client/src/utils/analytics.tsx @@ -41,7 +41,7 @@ export function useAnalyticsConsentValue(): boolean | null { ]) } -export type AnalyticsOptions = +type AnalyticsOptions = | { optedIn: true user: diff --git a/apps/dotcom/client/wait-for-postgres.sh b/apps/dotcom/client/wait-for-postgres.sh deleted file mode 100755 index 28c6595f9fff..000000000000 --- a/apps/dotcom/client/wait-for-postgres.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -until curl -s http://localhost:7654 | grep -q "ok"; do - echo "Waiting for Postgres to be ready..." - sleep 2 -done -echo "Postgres is ready!" \ No newline at end of file diff --git a/apps/dotcom/wait-for-migrations.sh b/apps/dotcom/wait-for-migrations.sh new file mode 100755 index 000000000000..3d0b95a0e0c3 --- /dev/null +++ b/apps/dotcom/wait-for-migrations.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Block until `apps/dotcom/zero-cache`'s `migrate --signal-success` HTTP server +# replies "ok" on localhost:7654, which means Postgres is up and all migrations +# have been applied. Used by `apps/dotcom/client` and `apps/dotcom/zero-cache` +# dev scripts to avoid racing against migration startup. + +until curl -s http://localhost:7654 | grep -q "ok"; do + echo "Waiting for migrations to finish..." + sleep 2 +done +echo "Migrations are ready!" diff --git a/apps/dotcom/zero-cache/package.json b/apps/dotcom/zero-cache/package.json index 470a421ce339..c7bbed9d5f90 100644 --- a/apps/dotcom/zero-cache/package.json +++ b/apps/dotcom/zero-cache/package.json @@ -14,7 +14,7 @@ "dev": "concurrently 'yarn docker-up' 'yarn migrate --signal-success' 'yarn bundle-schema:watch' 'yarn zero-server'", "bundle-schema": "esbuild --bundle --platform=node --format=esm --outfile=./.schema.js ../../../packages/dotcom-shared/src/tlaSchema.ts", "bundle-schema:watch": "esbuild --bundle --watch --platform=node --format=esm --outfile=./.schema.js ../../../packages/dotcom-shared/src/tlaSchema.ts", - "zero-server": "nodemon --watch ./.schema.js --exec 'zero-cache-dev' --signal SIGINT", + "zero-server": "../wait-for-migrations.sh && nodemon --watch ./.schema.js --exec 'zero-cache-dev' --signal SIGINT", "docker-up": "docker compose --env-file .env -f ./docker/docker-compose.yml up", "docker-down": "docker compose --env-file .env -f ./docker/docker-compose.yml down", "migrate": "yarn tsx ./migrate.ts", diff --git a/apps/examples/src/misc/end-to-end.tsx b/apps/examples/src/misc/end-to-end.tsx index a55e4fee6a8c..eec8d699569d 100644 --- a/apps/examples/src/misc/end-to-end.tsx +++ b/apps/examples/src/misc/end-to-end.tsx @@ -30,7 +30,7 @@ declare module 'tldraw' { } } -export type HtmlCssShape = TLShape +type HtmlCssShape = TLShape class HtmlCssShapeUtil extends BaseBoxShapeUtil { static override type = HTML_TYPE diff --git a/apps/mcp-app/src/shared/generated-data.ts b/apps/mcp-app/src/shared/generated-data.ts index b2e17a1578aa..6a47cd29ee87 100644 --- a/apps/mcp-app/src/shared/generated-data.ts +++ b/apps/mcp-app/src/shared/generated-data.ts @@ -1,4 +1,4 @@ -export type ArgKind = +type ArgKind = | 'id' | 'id-or-shape' | 'ids-or-shapes' @@ -18,7 +18,7 @@ export type RetKind = | 'ids' | 'id-set' -export interface MethodSpec { +interface MethodSpec { args: ArgKind[] ret: RetKind } diff --git a/apps/mcp-app/src/widget/exec-helpers.ts b/apps/mcp-app/src/widget/exec-helpers.ts index 5e4c074e88fd..563e7f7b4386 100644 --- a/apps/mcp-app/src/widget/exec-helpers.ts +++ b/apps/mcp-app/src/widget/exec-helpers.ts @@ -144,7 +144,7 @@ export function createExecHelpers(editor: Editor) { return helpers } -export type ExecHelpers = ReturnType +type ExecHelpers = ReturnType const EXEC_TIMEOUT_MS = 10_000 diff --git a/apps/mcp-app/src/widget/focused/format.ts b/apps/mcp-app/src/widget/focused/format.ts index f9b5e5591083..986d6bec8a00 100644 --- a/apps/mcp-app/src/widget/focused/format.ts +++ b/apps/mcp-app/src/widget/focused/format.ts @@ -22,7 +22,7 @@ export const FOCUSED_COLORS = [ 'white', ] as const -export type FocusedColor = (typeof FOCUSED_COLORS)[number] +type FocusedColor = (typeof FOCUSED_COLORS)[number] export function asColor(color: string): FocusedColor { if (FOCUSED_COLORS.includes(color as FocusedColor)) { @@ -38,7 +38,7 @@ export function asColor(color: string): FocusedColor { // ---- Fill ---- -export type FocusedFill = 'none' | 'tint' | 'background' | 'solid' | 'pattern' +type FocusedFill = 'none' | 'tint' | 'background' | 'solid' | 'pattern' const FOCUSED_TO_SHAPE_FILLS: Record = { none: 'none', diff --git a/apps/mcp-app/src/widget/persistence.ts b/apps/mcp-app/src/widget/persistence.ts index b2410d17f89c..a65d1cb1417c 100644 --- a/apps/mcp-app/src/widget/persistence.ts +++ b/apps/mcp-app/src/widget/persistence.ts @@ -177,7 +177,7 @@ function toAssetRecords(value: unknown): TLAsset[] { return value.filter((a): a is TLAsset => isPlainObject(a) && typeof a.id === 'string') } -export interface CheckpointResult { +interface CheckpointResult { checkpointId: string sessionId: string | null shapes: TLShape[] diff --git a/internal/health-worker/src/updown_types.ts b/internal/health-worker/src/updown_types.ts index e7f4ca7df740..7dd6c9862676 100644 --- a/internal/health-worker/src/updown_types.ts +++ b/internal/health-worker/src/updown_types.ts @@ -1,6 +1,6 @@ // docs: https://updown.io/api#webhooks -export interface BaseCheck { +interface BaseCheck { token: string url: string alias: null @@ -23,41 +23,41 @@ export interface BaseCheck { http_body: string } -export interface FailingCheck extends BaseCheck { +interface FailingCheck extends BaseCheck { down: true down_since: string up_since: null error: string } -export interface SucceedingCheck extends BaseCheck { +interface SucceedingCheck extends BaseCheck { down: true down_since: null up_since: string error: null } -export interface BaseDowntime { +interface BaseDowntime { id: string error: string started_at: string partial: unknown } -export interface OngoingDowntime extends BaseDowntime { +interface OngoingDowntime extends BaseDowntime { ended_at: null duration: null } -export interface FinishedDowntime extends BaseDowntime { +interface FinishedDowntime extends BaseDowntime { ended_at: string // seconds duration: number } -export type CustomHeaders = Record +type CustomHeaders = Record -export interface SslCert { +interface SslCert { subject: string issuer: string from: string @@ -65,7 +65,7 @@ export interface SslCert { algorithm: string } -export interface EventDown { +interface EventDown { event: 'check.down' time: string description: string @@ -73,7 +73,7 @@ export interface EventDown { downtime: OngoingDowntime } -export interface EventStillDown { +interface EventStillDown { event: 'check.still_down' time: string description: string @@ -81,7 +81,7 @@ export interface EventStillDown { downtime: OngoingDowntime } -export interface EventUp { +interface EventUp { event: 'check.up' time: string description: string @@ -89,7 +89,7 @@ export interface EventUp { downtime: FinishedDowntime } -export interface EventSslInvalid { +interface EventSslInvalid { event: 'check.ssl_invalid' time: string description: string @@ -100,7 +100,7 @@ export interface EventSslInvalid { } } -export interface EventSslValid { +interface EventSslValid { event: 'check.ssl_valid' time: string description: string @@ -110,7 +110,7 @@ export interface EventSslValid { } } -export interface EventSslExpiration { +interface EventSslExpiration { event: 'check.ssl_expiration' time: string description: string @@ -121,7 +121,7 @@ export interface EventSslExpiration { } } -export interface EventSslRenewed { +interface EventSslRenewed { event: 'check.ssl_renewed' time: string description: string @@ -132,7 +132,7 @@ export interface EventSslRenewed { } } -export interface EventPerformanceDrop { +interface EventPerformanceDrop { event: 'check.performance_drop' time: string description: string diff --git a/packages/editor/api-report.api.md b/packages/editor/api-report.api.md index bea039376046..83b97362e512 100644 --- a/packages/editor/api-report.api.md +++ b/packages/editor/api-report.api.md @@ -1928,6 +1928,12 @@ export function getGlobalWindow(): Window & typeof globalThis; // @public export function getIncrementedName(name: string, others: string[]): string; +// @public +export function getOverlayDisplayValues(util: { + editor: Editor; + options: OverlayOptionsWithDisplayValues; +}, overlay: Overlay, colorMode?: 'dark' | 'light'): DisplayValues; + // @internal (undocumented) export function getOwnerDocument(nodeOrDocument: Document | Node | null | undefined): Document; @@ -2626,6 +2632,14 @@ export class OverlayManager { setHoveredOverlay(id: null | string): void; } +// @public (undocumented) +export interface OverlayOptionsWithDisplayValues { + // (undocumented) + getCustomDisplayValues(editor: Editor, overlay: Overlay, theme: TLTheme, colorMode: 'dark' | 'light'): Partial; + // (undocumented) + getDefaultDisplayValues(editor: Editor, overlay: Overlay, theme: TLTheme, colorMode: 'dark' | 'light'): DisplayValues; +} + // @public export abstract class OverlayUtil { constructor(editor: Editor); diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 15db8a1696cf..b5b9bd4ab6ae 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -174,6 +174,10 @@ export { type TLShapeUtilCanvasSvgDef, type TLShapeUtilConstructor, } from './lib/editor/shapes/ShapeUtil' +export { + getOverlayDisplayValues, + type OverlayOptionsWithDisplayValues, +} from './lib/editor/overlays/getOverlayDisplayValues' export { OverlayManager, type TLOverlayEntry } from './lib/editor/overlays/OverlayManager' export { OverlayUtil, diff --git a/packages/editor/src/lib/editor/overlays/getOverlayDisplayValues.ts b/packages/editor/src/lib/editor/overlays/getOverlayDisplayValues.ts new file mode 100644 index 000000000000..972f210ea0c9 --- /dev/null +++ b/packages/editor/src/lib/editor/overlays/getOverlayDisplayValues.ts @@ -0,0 +1,51 @@ +import { TLTheme } from '@tldraw/tlschema' +import type { Editor } from '../Editor' +import { TLOverlay } from './OverlayUtil' + +/** @public */ +export interface OverlayOptionsWithDisplayValues< + Overlay extends TLOverlay, + DisplayValues extends object, +> { + getDefaultDisplayValues( + editor: Editor, + overlay: Overlay, + theme: TLTheme, + colorMode: 'light' | 'dark' + ): DisplayValues + getCustomDisplayValues( + editor: Editor, + overlay: Overlay, + theme: TLTheme, + colorMode: 'light' | 'dark' + ): Partial +} + +const dvCache = new WeakMap< + TLOverlay, + { theme: TLTheme; colorMode: 'light' | 'dark'; values: object } +>() + +/** + * Get the resolved display values for an overlay, merging the base values with any overrides. + * + * @public + */ +export function getOverlayDisplayValues( + util: { editor: Editor; options: OverlayOptionsWithDisplayValues }, + overlay: Overlay, + colorMode?: 'light' | 'dark' +): DisplayValues { + const theme = util.editor.getCurrentTheme() + const resolvedColorMode = colorMode ?? util.editor.getColorMode() + const cached = dvCache.get(overlay) + if (cached && cached.theme === theme && cached.colorMode === resolvedColorMode) { + return cached.values as DisplayValues + } + const values = { + ...util.options.getDefaultDisplayValues(util.editor, overlay, theme, resolvedColorMode), + ...util.options.getCustomDisplayValues(util.editor, overlay, theme, resolvedColorMode), + } + dvCache.set(overlay, { theme, colorMode: resolvedColorMode, values }) + return values +} diff --git a/packages/tldraw/api-report.api.md b/packages/tldraw/api-report.api.md index 94c46b70e5ba..f89b4fda1157 100644 --- a/packages/tldraw/api-report.api.md +++ b/packages/tldraw/api-report.api.md @@ -43,6 +43,7 @@ import { MigrationFailureReason } from '@tldraw/editor'; import { MigrationSequence } from '@tldraw/store'; import { NamedExoticComponent } from 'react'; import { Node as Node_2 } from '@tiptap/pm/model'; +import { OverlayOptionsWithDisplayValues } from '@tldraw/editor'; import { OverlayUtil } from '@tldraw/editor'; import { PerfectDashTerminal } from '@tldraw/editor'; import { PointerEvent as PointerEvent_2 } from 'react'; @@ -124,7 +125,6 @@ import { TLImageAsset } from '@tldraw/editor'; import { TLImageExportOptions } from '@tldraw/editor'; import { TLImageShape } from '@tldraw/editor'; import { TLImageShapeProps } from '@tldraw/editor'; -import { TLIndicatorPath } from '@tldraw/editor'; import { TLKeyboardEventInfo } from '@tldraw/editor'; import { TLLineShape } from '@tldraw/editor'; import { TLLineShapePoint } from '@tldraw/editor'; @@ -207,6 +207,7 @@ export class ArrowBindingHintOverlayUtil extends OverlayUtil { }; // (undocumented) render(ctx: CanvasRenderingContext2D, overlays: TLArrowHintOverlay[]): void; - // @internal (undocumented) - _renderIndicatorPath(ctx: CanvasRenderingContext2D, indicatorPath: TLIndicatorPath): void; // (undocumented) static type: string; } @@ -616,10 +615,7 @@ export class BrushOverlayUtil extends OverlayUtil { // (undocumented) isActive(): boolean; // (undocumented) - options: { - lineWidth: number; - zIndex: number; - }; + options: BrushOverlayUtilOptions; // (undocumented) render(ctx: CanvasRenderingContext2D, overlays: TLBrushOverlay[]): void; // (undocumented) @@ -628,6 +624,22 @@ export class BrushOverlayUtil extends OverlayUtil { static type: string; } +// @public (undocumented) +export interface BrushOverlayUtilDisplayValues { + // (undocumented) + fillColor: string; + // (undocumented) + lineWidth: number; + // (undocumented) + strokeColor: string; +} + +// @public (undocumented) +export interface BrushOverlayUtilOptions extends OverlayOptionsWithDisplayValues { + // (undocumented) + zIndex: number; +} + // @internal (undocumented) export function buildFromV1Document(editor: Editor, _document: unknown): void; @@ -711,16 +723,6 @@ export class CollaboratorHintOverlayUtil extends OverlayUtil markerRadius + 0.5) { - const visibleDist = dist - markerRadius - const dashLength = Math.min(strokeWidth * this.options.dashLengthRatio, visibleDist / 2) ctx.save() - ctx.setLineDash([dashLength, dashLength]) - ctx.lineDashOffset = -dashLength / 2 ctx.beginPath() + let pathLength: number + if (info.type === 'arc') { // Render along the body arc so the stub sits on the same circle as // the visible arrow body; project handle radially onto that circle. @@ -161,12 +170,22 @@ export class ArrowBindingHintOverlayUtil extends OverlayUtil Math.PI) handleToPointSweep -= PI2 + else if (handleToPointSweep < -Math.PI) handleToPointSweep += PI2 + pathLength = Math.max(0, radius * Math.abs(handleToPointSweep) - markerRadius) + if (side === 'start') { ctx.arc(center.x, center.y, radius, trimmedHandleAngle, pointAngle, anticlockwise) } else { ctx.arc(center.x, center.y, radius, pointAngle, trimmedHandleAngle, anticlockwise) } } else { + pathLength = dist - markerRadius const t = markerRadius / dist const trimmedHandle = { x: handle.x + (point.x - handle.x) * t, @@ -176,6 +195,22 @@ export class ArrowBindingHintOverlayUtil extends OverlayUtil= this.options.dashedMinZoom) { + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(pathLength, strokeWidth, { + style: 'dashed', + end: 'skip', + start: 'skip', + }) + + if (strokeDasharray !== 'none') { + const [dashLength, gapLength] = strokeDasharray.split(' ').map(Number) + ctx.setLineDash([dashLength, gapLength]) + ctx.lineDashOffset = Number(strokeDashoffset) + } + } + ctx.stroke() ctx.restore() } diff --git a/packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts b/packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts index f6616f4d5471..68239e56b4c3 100644 --- a/packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts +++ b/packages/tldraw/src/lib/overlays/ArrowHintOverlayUtil.ts @@ -30,6 +30,11 @@ const indicatorPathCache = createComputedCache( (editor: Editor, shape: TLShape) => { const util = editor.getShapeUtil(shape) return util.getIndicatorPath(shape) + }, + { + areRecordsEqual(a, b) { + return a.props === b.props + }, } ) @@ -185,7 +190,7 @@ export class ArrowHintOverlayUtil extends OverlayUtil { } /** @internal */ - _renderIndicatorPath(ctx: CanvasRenderingContext2D, indicatorPath: TLIndicatorPath) { + private _renderIndicatorPath(ctx: CanvasRenderingContext2D, indicatorPath: TLIndicatorPath) { if (indicatorPath instanceof Path2D) { ctx.stroke(indicatorPath) } else { diff --git a/packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts b/packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts index 17b74e581f74..12bc58dfb546 100644 --- a/packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts +++ b/packages/tldraw/src/lib/overlays/BrushOverlayUtil.ts @@ -1,4 +1,9 @@ -import { OverlayUtil, TLOverlay } from '@tldraw/editor' +import { + getOverlayDisplayValues, + OverlayOptionsWithDisplayValues, + OverlayUtil, + TLOverlay, +} from '@tldraw/editor' /** @public */ export interface TLBrushOverlay extends TLOverlay { @@ -10,6 +15,21 @@ export interface TLBrushOverlay extends TLOverlay { } } +/** @public */ +export interface BrushOverlayUtilDisplayValues { + fillColor: string + strokeColor: string + lineWidth: number +} + +/** @public */ +export interface BrushOverlayUtilOptions extends OverlayOptionsWithDisplayValues< + TLBrushOverlay, + BrushOverlayUtilDisplayValues +> { + zIndex: number +} + /** * Overlay util for the selection brush rectangle. * @@ -17,7 +37,20 @@ export interface TLBrushOverlay extends TLOverlay { */ export class BrushOverlayUtil extends OverlayUtil { static override type = 'brush' - override options = { zIndex: 300, lineWidth: 1 } + override options: BrushOverlayUtilOptions = { + zIndex: 300, + getDefaultDisplayValues(editor, _overlay, theme, colorMode): BrushOverlayUtilDisplayValues { + const colors = theme.colors[colorMode] + return { + fillColor: colors.brushFill, + strokeColor: colors.brushStroke, + lineWidth: 1, + } + }, + getCustomDisplayValues(): Partial { + return {} + }, + } override isActive(): boolean { return this.editor.getInstanceState().brush !== null @@ -46,14 +79,14 @@ export class BrushOverlayUtil extends OverlayUtil { const { x, y, w, h } = overlay.props const zoom = this.editor.getZoomLevel() - const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] + const dv = getOverlayDisplayValues(this, overlay) // Use fillRect / strokeRect to avoid path construction overhead - ctx.fillStyle = colors.brushFill + ctx.fillStyle = dv.fillColor ctx.fillRect(x, y, w, h) - ctx.lineWidth = this.options.lineWidth / zoom - ctx.strokeStyle = colors.brushStroke + ctx.lineWidth = dv.lineWidth / zoom + ctx.strokeStyle = dv.strokeColor ctx.strokeRect(x, y, w, h) } @@ -65,11 +98,11 @@ export class BrushOverlayUtil extends OverlayUtil { const overlay = overlays[0] if (!overlay) return const { x, y, w, h } = overlay.props - const colors = this.editor.getCurrentTheme().colors[this.editor.getColorMode()] - ctx.fillStyle = colors.brushFill + const dv = getOverlayDisplayValues(this, overlay) + ctx.fillStyle = dv.fillColor ctx.fillRect(x, y, w, h) - ctx.lineWidth = this.options.lineWidth / zoom - ctx.strokeStyle = colors.brushStroke + ctx.lineWidth = dv.lineWidth / zoom + ctx.strokeStyle = dv.strokeColor ctx.strokeRect(x, y, w, h) } } diff --git a/packages/tldraw/src/lib/overlays/CollaboratorCursorOverlayUtil.ts b/packages/tldraw/src/lib/overlays/CollaboratorCursorOverlayUtil.ts index f52d8e61c077..9e8e4576c03d 100644 --- a/packages/tldraw/src/lib/overlays/CollaboratorCursorOverlayUtil.ts +++ b/packages/tldraw/src/lib/overlays/CollaboratorCursorOverlayUtil.ts @@ -73,7 +73,7 @@ export class CollaboratorCursorOverlayUtil extends OverlayUtil() override isActive(): boolean { - return this.getOverlays().length > 0 + return this.editor.getVisibleCollaboratorsOnCurrentPage().some((presence) => !!presence.cursor) } override getOverlays(): TLCollaboratorCursorOverlay[] { @@ -105,10 +105,13 @@ export class CollaboratorCursorOverlayUtil extends OverlayUtil | null = null for (const overlay of overlays) { const { x, y, color, name, chatMessage } = overlay.props @@ -117,10 +120,10 @@ export class CollaboratorCursorOverlayUtil extends OverlayUtil viewport.maxX - 12 / zoom || - y > viewport.maxY - 16 / zoom + x < viewport.minX - viewportMarginX || + y < viewport.minY - viewportMarginY || + x > viewport.maxX - viewportMarginX || + y > viewport.maxY - viewportMarginY ) { continue } @@ -130,7 +133,7 @@ export class CollaboratorCursorOverlayUtil extends OverlayUtil TRUNCATE_CACHE_MAX) this._truncateCache.clear() - this._truncateCache.set(key, text) - return text + return this._setTruncatedTextCache(key, text) } - let truncated = text - while (truncated.length > 0 && ctx.measureText(truncated + '…').width > maxWidth) { - truncated = truncated.slice(0, -1) + + const ellipsis = '…' + let low = 0 + let high = text.length + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) { + low = mid + } else { + high = mid - 1 + } } - const result = truncated + '…' - if (this._truncateCache.size > TRUNCATE_CACHE_MAX) this._truncateCache.clear() + + return this._setTruncatedTextCache(key, text.slice(0, low) + ellipsis) + } + + private _setTruncatedTextCache(key: string, result: string): string { + if (this._truncateCache.size >= TRUNCATE_CACHE_MAX) this._truncateCache.clear() this._truncateCache.set(key, result) return result } diff --git a/packages/tldraw/src/lib/overlays/CollaboratorHintOverlayUtil.ts b/packages/tldraw/src/lib/overlays/CollaboratorHintOverlayUtil.ts index d0d7b318cc67..cdc0856d4fe5 100644 --- a/packages/tldraw/src/lib/overlays/CollaboratorHintOverlayUtil.ts +++ b/packages/tldraw/src/lib/overlays/CollaboratorHintOverlayUtil.ts @@ -95,7 +95,7 @@ export class CollaboratorHintOverlayUtil extends OverlayUtil } } @@ -25,15 +25,22 @@ export class CollaboratorShapeIndicatorOverlayUtil extends OverlayUtil 0 + return this.editor + .getVisibleCollaboratorsOnCurrentPage() + .some((presence) => presence.selectedShapeIds.length > 0) } override getOverlays(): TLCollaboratorShapeIndicatorOverlay[] { const editor = this.editor + const selectedPresences = editor + .getVisibleCollaboratorsOnCurrentPage() + .filter((presence) => presence.selectedShapeIds.length > 0) + if (selectedPresences.length === 0) return [] + const renderingShapeIds = new Set(editor.getRenderingShapes().map((s) => s.id)) const indicators: TLCollaboratorShapeIndicatorOverlay['props']['indicators'] = [] - for (const presence of editor.getVisibleCollaboratorsOnCurrentPage()) { + for (const presence of selectedPresences) { const visibleShapeIds = presence.selectedShapeIds.filter( (id) => renderingShapeIds.has(id) && !editor.isShapeHidden(id) ) @@ -67,7 +74,7 @@ export class CollaboratorShapeIndicatorOverlayUtil extends OverlayUtil { @@ -320,16 +327,19 @@ export class SelectionForegroundOverlayUtil extends OverlayUtil { override isActive(): boolean { const editor = this.editor - const { isReadonly, isChangingStyle } = editor.getInstanceState() - if (isReadonly || isChangingStyle) return false + if (editor.getIsReadonly() || editor.getInstanceState().isChangingStyle) return false const onlySelectedShape = editor.getOnlySelectedShape() if (!onlySelectedShape) return false @@ -65,21 +64,32 @@ export class ShapeHandleOverlayUtil extends OverlayUtil { const minDist = ((isCoarse ? editor.options.coarseHandleRadius : editor.options.handleRadius) / zoom) * 2 + const vertexHandles = handles.filter((handle) => handle.type === 'vertex') + const vertexHandlesForHitTest: TLHandle[] = [] + const otherHandlesForHitTest: TLHandle[] = [] + // Vertex handles come first so they win hit-testing against overlapping // virtual/create handles (e.g., a line's midpoint create handle that // coincides with its endpoint vertices when a segment is very short). // `render` iterates this array in reverse so the painted order puts // vertex handles on top visually, matching the main branch's paint // order where vertex handles were sorted last. - const filtered = handles - .filter( - (handle) => - handle.type !== 'virtual' || - !handles.some((h) => h !== handle && h.type === 'vertex' && Vec.Dist(handle, h) < minDist) - ) - .sort((a) => (a.type === 'vertex' ? -1 : 1)) - - return filtered.map((handle) => ({ + for (const handle of handles) { + if ( + handle.type === 'virtual' && + vertexHandles.some((vertexHandle) => Vec.Dist(handle, vertexHandle) < minDist) + ) { + continue + } + + if (handle.type === 'vertex') { + vertexHandlesForHitTest.push(handle) + } else { + otherHandlesForHitTest.push(handle) + } + } + + return vertexHandlesForHitTest.concat(otherHandlesForHitTest).map((handle) => ({ id: `handle:${onlySelectedShape.id}:${handle.id}`, type: 'shape_handle', props: { diff --git a/packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts b/packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts index ebf1a71c66f5..7d3191b53a3f 100644 --- a/packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts +++ b/packages/tldraw/src/lib/overlays/SnapIndicatorOverlayUtil.ts @@ -60,12 +60,24 @@ export class SnapIndicatorOverlayUtil extends OverlayUtil Math.min(acc, p.x), Infinity) - const maxX = points.reduce((acc, p) => Math.max(acc, p.x), -Infinity) - const minY = points.reduce((acc, p) => Math.min(acc, p.y), Infinity) - const maxY = points.reduce((acc, p) => Math.max(acc, p.y), -Infinity) + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const point of points) { + if (point.x < minX) minX = point.x + if (point.x > maxX) maxX = point.x + if (point.y < minY) minY = point.y + if (point.y > maxY) maxY = point.y + } - const useNWtoSEdirection = points.some((p) => p.x === minX && p.y === minY) + let useNWtoSEdirection = false + for (const point of points) { + if (point.x === minX && point.y === minY) { + useNWtoSEdirection = true + break + } + } let firstX: number, firstY: number, secondX: number, secondY: number if (useNWtoSEdirection) { firstX = minX @@ -106,7 +118,10 @@ export class SnapIndicatorOverlayUtil extends OverlayUtil { + editor = new TestEditor({ overlayUtils: defaultOverlayUtils }) + editor.user.updateUserPreferences({ colorScheme: 'light' }) +}) + +afterEach(() => { + editor?.dispose() +}) + +function getBrushOverlay() { + editor.updateInstanceState({ brush: { x: 0, y: 0, w: 10, h: 10 } }) + const util = editor.overlays.getOverlayUtil('brush') + const overlay = util.getOverlays()[0] + return { util, overlay } +} + +describe('BrushOverlayUtil display values', () => { + it('resolves brush colors from theme', () => { + const { util, overlay } = getBrushOverlay() + const dv = getOverlayDisplayValues(util, overlay) + expect(dv.fillColor).toBe('hsl(0, 0%, 56%, 10.2%)') + expect(dv.strokeColor).toBe('hsl(0, 0%, 56%, 25.1%)') + expect(dv.lineWidth).toBe(1) + }) + + it('configure() override of lineWidth flows to display values', () => { + const Configured = BrushOverlayUtil.configure({ + getCustomDisplayValues() { + return { lineWidth: 5 } + }, + }) + editor.dispose() + editor = new TestEditor({ + overlayUtils: [...defaultOverlayUtils].map((u) => (u === BrushOverlayUtil ? Configured : u)), + }) + const { util, overlay } = getBrushOverlay() + const dv = getOverlayDisplayValues(util, overlay) + expect(dv.lineWidth).toBe(5) + // untouched defaults still apply + expect(dv.fillColor).toBe('hsl(0, 0%, 56%, 10.2%)') + }) + + it('configure() override of fillColor flows to display values', () => { + const Configured = BrushOverlayUtil.configure({ + getCustomDisplayValues() { + return { fillColor: '#CUSTOM_FILL' } + }, + }) + editor.dispose() + editor = new TestEditor({ + overlayUtils: [...defaultOverlayUtils].map((u) => (u === BrushOverlayUtil ? Configured : u)), + }) + const { util, overlay } = getBrushOverlay() + const dv = getOverlayDisplayValues(util, overlay) + expect(dv.fillColor).toBe('#CUSTOM_FILL') + }) + + it('caches by overlay identity for the same theme/colorMode', () => { + const { util, overlay } = getBrushOverlay() + const dv1 = getOverlayDisplayValues(util, overlay) + const dv2 = getOverlayDisplayValues(util, overlay) + expect(dv1).toBe(dv2) + }) + + it('invalidates when colorMode changes', () => { + const { util, overlay } = getBrushOverlay() + const lightDv = getOverlayDisplayValues(util, overlay) + editor.user.updateUserPreferences({ colorScheme: 'dark' }) + const darkDv = getOverlayDisplayValues(util, overlay) + expect(darkDv).not.toBe(lightDv) + }) + + it('invalidates when theme is updated', () => { + const { util, overlay } = getBrushOverlay() + const dv1 = getOverlayDisplayValues(util, overlay) + editor.updateTheme({ + ...editor.getTheme('default')!, + colors: { + ...editor.getTheme('default')!.colors, + light: { + ...editor.getTheme('default')!.colors.light, + brushFill: '#NEW_FILL', + }, + }, + }) + const dv2 = getOverlayDisplayValues(util, overlay) + expect(dv2).not.toBe(dv1) + expect(dv2.fillColor).toBe('#NEW_FILL') + }) +})