editor: register timeouts/intervals/rafs for disposal (#3852)
We have a lot of events that fire in the editor and, technically, they can fire after the Editor is long gone. This adds a registry/manager to track those timeout/interval/raf IDs (and some eslint rules to enforce it). Some other cleanups: - `requestAnimationFrame.polyfill.ts` looks like it's unused now (it used to be used in a prev. revision) - @ds300 I could use your feedback on the `EffectScheduler` tweak. in `useReactor` we do: `() => new EffectScheduler(name, reactFn, { scheduleEffect: (cb) => requestAnimationFrame(cb) }),` and that looks like it doesn't currently get disposed of properly. thoughts? happy to do that separately from this PR if you think that's a trickier thing. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Test async operations and make sure they don't fire after disposal. ### Release Notes - Editor: add registry of timeouts/intervals/rafs --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
23cf8729bc
commit
aadc0aab4d
35 changed files with 185 additions and 76 deletions
45
.eslintrc.js
45
.eslintrc.js
|
@ -85,6 +85,44 @@ module.exports = {
|
|||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/editor/**/*', 'packages/tldraw/**/*'],
|
||||
rules: {
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: 'setInterval',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
{
|
||||
name: 'setTimeout',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
{
|
||||
name: 'requestAnimationFrame',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'window',
|
||||
property: 'setTimeout',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
{
|
||||
object: 'window',
|
||||
property: 'setInterval',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
{
|
||||
object: 'window',
|
||||
property: 'requestAnimationFrame',
|
||||
message: 'Use the timers from @tldraw/util instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['e2e/**/*'],
|
||||
rules: {
|
||||
|
@ -97,6 +135,13 @@ module.exports = {
|
|||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.test.ts', '*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-properties': 'off',
|
||||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['apps/examples/**/*'],
|
||||
rules: {
|
||||
|
|
|
@ -19,10 +19,9 @@ export function CustomRenderer() {
|
|||
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
let isCancelled = false
|
||||
let raf = -1
|
||||
|
||||
function render() {
|
||||
if (isCancelled) return
|
||||
if (!canvas) return
|
||||
|
||||
ctx.resetTransform()
|
||||
|
@ -94,13 +93,13 @@ export function CustomRenderer() {
|
|||
ctx.restore()
|
||||
}
|
||||
|
||||
requestAnimationFrame(render)
|
||||
raf = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
|
|
|
@ -80,15 +80,15 @@ export default function UserPresenceExample() {
|
|||
])
|
||||
})
|
||||
|
||||
rRaf.current = requestAnimationFrame(loop)
|
||||
rRaf.current = editor.timers.requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
rRaf.current = requestAnimationFrame(loop)
|
||||
rRaf.current = editor.timers.requestAnimationFrame(loop)
|
||||
} else {
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||
})
|
||||
rRaf.current = setInterval(() => {
|
||||
rRaf.current = editor.timers.setInterval(() => {
|
||||
editor.store.mergeRemoteChanges(() => {
|
||||
editor.store.put([{ ...peerPresence, lastActivityTimestamp: Date.now() }])
|
||||
})
|
||||
|
|
|
@ -40,6 +40,7 @@ import { StoreSchema } from '@tldraw/store';
|
|||
import { StoreSideEffects } from '@tldraw/store';
|
||||
import { StyleProp } from '@tldraw/tlschema';
|
||||
import { StylePropValue } from '@tldraw/tlschema';
|
||||
import { Timers } from '@tldraw/utils';
|
||||
import { TLAsset } from '@tldraw/tlschema';
|
||||
import { TLAssetId } from '@tldraw/tlschema';
|
||||
import { TLAssetPartial } from '@tldraw/tlschema';
|
||||
|
@ -1107,6 +1108,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
[key: string]: Map<StyleProp<any>, string>;
|
||||
};
|
||||
readonly textMeasure: TextManager;
|
||||
readonly timers: Timers;
|
||||
toggleLock(shapes: TLShape[] | TLShapeId[]): this;
|
||||
undo(): this;
|
||||
ungroupShapes(ids: TLShapeId[], options?: Partial<{
|
||||
|
|
|
@ -165,7 +165,7 @@ function useCollaboratorState(editor: Editor, latestPresence: TLInstancePresence
|
|||
)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const interval = editor.timers.setInterval(() => {
|
||||
setState(getStateFromElapsedTime(editor, Date.now() - rLastActivityTimestamp.current))
|
||||
}, editor.options.collaboratorCheckIntervalMs)
|
||||
|
||||
|
|
|
@ -82,12 +82,12 @@ export const DefaultErrorFallback: TLErrorFallbackComponent = ({ error, editor }
|
|||
|
||||
useEffect(() => {
|
||||
if (didCopy) {
|
||||
const timeout = setTimeout(() => {
|
||||
const timeout = editor?.timers.setTimeout(() => {
|
||||
setDidCopy(false)
|
||||
}, 2000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [didCopy])
|
||||
}, [didCopy, editor])
|
||||
|
||||
const copyError = () => {
|
||||
const textarea = document.createElement('textarea')
|
||||
|
|
|
@ -57,6 +57,7 @@ import {
|
|||
JsonObject,
|
||||
PerformanceTracker,
|
||||
Result,
|
||||
Timers,
|
||||
annotateError,
|
||||
assert,
|
||||
assertExists,
|
||||
|
@ -240,6 +241,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
this.snaps = new SnapManager(this)
|
||||
|
||||
this.timers = new Timers()
|
||||
this.disposables.add(this.timers.dispose.bind(this.timers))
|
||||
|
||||
this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions })
|
||||
|
||||
this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false)
|
||||
|
@ -687,7 +691,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
this.on('tick', this._flushEventsForTick)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.timers.requestAnimationFrame(() => {
|
||||
this._tickManager.start()
|
||||
})
|
||||
|
||||
|
@ -727,6 +731,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
*/
|
||||
readonly snaps: SnapManager
|
||||
|
||||
/**
|
||||
* A manager for the any asynchronous events and making sure they're
|
||||
* cleaned up upon disposal.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
readonly timers: Timers
|
||||
|
||||
/**
|
||||
* A manager for the user and their preferences.
|
||||
*
|
||||
|
@ -1246,7 +1258,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
clearTimeout(this._isChangingStyleTimeout)
|
||||
if (partial.isChangingStyle === true) {
|
||||
// If we've set to true, set a new reset timeout to change the value back to false after 2 seconds
|
||||
this._isChangingStyleTimeout = setTimeout(() => {
|
||||
this._isChangingStyleTimeout = this.timers.setTimeout(() => {
|
||||
this._updateInstanceState({ isChangingStyle: false }, { history: 'ignore' })
|
||||
}, 2000)
|
||||
}
|
||||
|
@ -2958,7 +2970,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
this.updateInstanceState({ highlightedUserIds: [...highlightedUserIds, userId] })
|
||||
|
||||
// Unhighlight the user's cursor after a few seconds
|
||||
setTimeout(() => {
|
||||
this.timers.setTimeout(() => {
|
||||
const highlightedUserIds = [...this.getInstanceState().highlightedUserIds]
|
||||
const index = highlightedUserIds.indexOf(userId)
|
||||
if (index < 0) return
|
||||
|
@ -8440,27 +8452,27 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
if (info.shiftKey) {
|
||||
clearInterval(this._shiftKeyTimeout)
|
||||
clearTimeout(this._shiftKeyTimeout)
|
||||
this._shiftKeyTimeout = -1
|
||||
inputs.shiftKey = true
|
||||
} else if (!info.shiftKey && inputs.shiftKey && this._shiftKeyTimeout === -1) {
|
||||
this._shiftKeyTimeout = setTimeout(this._setShiftKeyTimeout, 150)
|
||||
this._shiftKeyTimeout = this.timers.setTimeout(this._setShiftKeyTimeout, 150)
|
||||
}
|
||||
|
||||
if (info.altKey) {
|
||||
clearInterval(this._altKeyTimeout)
|
||||
clearTimeout(this._altKeyTimeout)
|
||||
this._altKeyTimeout = -1
|
||||
inputs.altKey = true
|
||||
} else if (!info.altKey && inputs.altKey && this._altKeyTimeout === -1) {
|
||||
this._altKeyTimeout = setTimeout(this._setAltKeyTimeout, 150)
|
||||
this._altKeyTimeout = this.timers.setTimeout(this._setAltKeyTimeout, 150)
|
||||
}
|
||||
|
||||
if (info.ctrlKey) {
|
||||
clearInterval(this._ctrlKeyTimeout)
|
||||
clearTimeout(this._ctrlKeyTimeout)
|
||||
this._ctrlKeyTimeout = -1
|
||||
inputs.ctrlKey = true
|
||||
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
|
||||
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
|
||||
this._ctrlKeyTimeout = this.timers.setTimeout(this._setCtrlKeyTimeout, 150)
|
||||
}
|
||||
|
||||
const { originPagePoint, currentPagePoint } = inputs
|
||||
|
@ -8645,7 +8657,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
if (!this.inputs.isPanning) {
|
||||
// Start a long press timeout
|
||||
this._longPressTimeout = setTimeout(() => {
|
||||
this._longPressTimeout = this.timers.setTimeout(() => {
|
||||
this.dispatch({
|
||||
...info,
|
||||
point: this.inputs.currentScreenPoint,
|
||||
|
@ -8879,7 +8891,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
} else {
|
||||
this.performanceTracker.start(name)
|
||||
}
|
||||
this.performanceTrackerTimeout = setTimeout(() => {
|
||||
this.performanceTrackerTimeout = this.timers.setTimeout(() => {
|
||||
this.performanceTracker.stop()
|
||||
}, 50)
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export class ClickManager {
|
|||
private _getClickTimeout = (state: TLClickState, id = uniqueId()) => {
|
||||
this._clickId = id
|
||||
clearTimeout(this._clickTimeout)
|
||||
this._clickTimeout = setTimeout(
|
||||
this._clickTimeout = this.editor.timers.setTimeout(
|
||||
() => {
|
||||
if (this._clickState === state && this._clickId === id) {
|
||||
switch (this._clickState) {
|
||||
|
|
|
@ -7,6 +7,7 @@ const throttleToNextFrame =
|
|||
? // At test time we should use actual raf and not throttle, because throttle was set up to evaluate immediately during tests, which causes stack overflow
|
||||
// for the tick manager since it sets up a raf loop.
|
||||
function mockThrottle(cb: any) {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const frame = requestAnimationFrame(cb)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}
|
||||
|
|
|
@ -273,7 +273,7 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
|
|||
|
||||
pinchState = 'not sure'
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
editor.dispatch({
|
||||
type: 'pinch',
|
||||
name: 'pinch_end',
|
||||
|
|
|
@ -37,7 +37,7 @@ export function useScreenBounds(ref: React.RefObject<HTMLElement>) {
|
|||
|
||||
// Rather than running getClientRects on every frame, we'll
|
||||
// run it once a second or when the window resizes.
|
||||
const interval = setInterval(updateBounds, 1000)
|
||||
const interval = editor.timers.setInterval(updateBounds, 1000)
|
||||
window.addEventListener('resize', updateBounds)
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
|
|
|
@ -266,6 +266,7 @@ export class TLLocalSyncClient {
|
|||
|
||||
private isPersisting = false
|
||||
private didLastWriteError = false
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
|
@ -277,6 +278,7 @@ export class TLLocalSyncClient {
|
|||
private schedulePersist() {
|
||||
this.debug('schedulePersist', this.scheduledPersistTimeout)
|
||||
if (this.scheduledPersistTimeout) return
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
this.scheduledPersistTimeout = setTimeout(
|
||||
() => {
|
||||
this.scheduledPersistTimeout = null
|
||||
|
|
|
@ -35,7 +35,7 @@ interface EffectSchedulerOptions {
|
|||
* @param execute - A function that will execute the effect.
|
||||
* @returns
|
||||
*/
|
||||
scheduleEffect?: (execute: () => void) => void
|
||||
scheduleEffect?: (execute: () => void) => number | void | (() => void)
|
||||
}
|
||||
|
||||
class __EffectScheduler__<Result> {
|
||||
|
@ -63,13 +63,15 @@ class __EffectScheduler__<Result> {
|
|||
return this._scheduleCount
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private maybeRaf: number | void | (() => void) = -1
|
||||
/** @internal */
|
||||
readonly parentSet = new ArraySet<Signal<any, any>>()
|
||||
/** @internal */
|
||||
readonly parentEpochs: number[] = []
|
||||
/** @internal */
|
||||
readonly parents: Signal<any, any>[] = []
|
||||
private readonly _scheduleEffect?: (execute: () => void) => void
|
||||
private readonly _scheduleEffect?: (execute: () => void) => number | void | (() => void)
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private readonly runEffect: (lastReactedEpoch: number) => Result,
|
||||
|
@ -99,7 +101,7 @@ class __EffectScheduler__<Result> {
|
|||
this._scheduleCount++
|
||||
if (this._scheduleEffect) {
|
||||
// if the effect should be deferred (e.g. until a react render), do so
|
||||
this._scheduleEffect(this.maybeExecute)
|
||||
this.maybeRaf = this._scheduleEffect(this.maybeExecute)
|
||||
} else {
|
||||
// otherwise execute right now!
|
||||
this.execute()
|
||||
|
@ -135,6 +137,7 @@ class __EffectScheduler__<Result> {
|
|||
for (let i = 0, n = this.parents.length; i < n; i++) {
|
||||
detach(this.parents[i], this)
|
||||
}
|
||||
typeof this.maybeRaf === 'number' && cancelAnimationFrame(this.maybeRaf)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -841,7 +841,7 @@ export function getPerfectDashProps(totalLength: number, strokeWidth: number, op
|
|||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
|
||||
export function getSvgAsImage(editor: Editor, svgString: string, options: {
|
||||
height: number;
|
||||
quality: number;
|
||||
scale: number;
|
||||
|
|
|
@ -137,7 +137,7 @@ export const TldrawImage = memo(function TldrawImage(props: TldrawImageProps) {
|
|||
setUrl(url)
|
||||
}
|
||||
} else if (format === 'png') {
|
||||
const blob = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
|
||||
const blob = await getSvgAsImage(editor, svgResult.svg, {
|
||||
type: format,
|
||||
quality: 1,
|
||||
scale: 2,
|
||||
|
|
|
@ -170,7 +170,7 @@ export class Pointing extends StateNode {
|
|||
private preciseTimeout = -1
|
||||
private didTimeout = false
|
||||
private startPreciseTimeout() {
|
||||
this.preciseTimeout = window.setTimeout(() => {
|
||||
this.preciseTimeout = this.editor.timers.setTimeout(() => {
|
||||
if (!this.getIsActive()) return
|
||||
this.didTimeout = true
|
||||
}, 320)
|
||||
|
|
|
@ -280,11 +280,11 @@ function PatternFillDefForCanvas() {
|
|||
const htmlLayer = findHtmlLayerParent(containerRef.current!)
|
||||
if (htmlLayer) {
|
||||
// Wait for `patternContext` to be picked up
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
htmlLayer.style.display = 'none'
|
||||
|
||||
// Wait for 'display = "none"' to take effect
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
htmlLayer.style.display = ''
|
||||
})
|
||||
})
|
||||
|
|
|
@ -12,7 +12,7 @@ export class DragAndDropManager {
|
|||
|
||||
prevDroppingShapeId: TLShapeId | null = null
|
||||
|
||||
droppingNodeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
droppingNodeTimer: number | null = null
|
||||
|
||||
first = true
|
||||
|
||||
|
@ -33,13 +33,13 @@ export class DragAndDropManager {
|
|||
if (this.droppingNodeTimer === null) {
|
||||
this.setDragTimer(movingShapes, INITIAL_POINTER_LAG_DURATION, cb)
|
||||
} else if (this.editor.inputs.pointerVelocity.len() > 0.5) {
|
||||
clearInterval(this.droppingNodeTimer)
|
||||
clearTimeout(this.droppingNodeTimer)
|
||||
this.setDragTimer(movingShapes, FAST_POINTER_LAG_DURATION, cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setDragTimer(movingShapes: TLShape[], duration: number, cb: () => void) {
|
||||
this.droppingNodeTimer = setTimeout(() => {
|
||||
this.droppingNodeTimer = this.editor.timers.setTimeout(() => {
|
||||
this.editor.batch(() => {
|
||||
this.handleDrag(this.editor.inputs.currentPagePoint, movingShapes, cb)
|
||||
})
|
||||
|
@ -129,7 +129,7 @@ export class DragAndDropManager {
|
|||
this.prevDroppingShapeId = null
|
||||
|
||||
if (this.droppingNodeTimer !== null) {
|
||||
clearInterval(this.droppingNodeTimer)
|
||||
clearTimeout(this.droppingNodeTimer)
|
||||
}
|
||||
|
||||
this.droppingNodeTimer = null
|
||||
|
|
|
@ -145,7 +145,7 @@ export class DraggingHandle extends StateNode {
|
|||
this.clearExactTimeout()
|
||||
}
|
||||
|
||||
this.exactTimeout = setTimeout(() => {
|
||||
this.exactTimeout = this.editor.timers.setTimeout(() => {
|
||||
if (this.getIsActive() && !this.isPrecise) {
|
||||
this.isPrecise = true
|
||||
this.isPreciseId = this.pointingId
|
||||
|
|
|
@ -54,6 +54,7 @@ const CurrentState = track(function CurrentState() {
|
|||
})
|
||||
|
||||
function FPS() {
|
||||
const editor = useEditor()
|
||||
const showFps = useValue('show_fps', () => debugFlags.showFps.get(), [debugFlags])
|
||||
|
||||
const fpsRef = useRef<HTMLDivElement>(null)
|
||||
|
@ -63,7 +64,7 @@ function FPS() {
|
|||
|
||||
const TICK_LENGTH = 250
|
||||
let maxKnownFps = 0
|
||||
let cancelled = false
|
||||
let raf = -1
|
||||
|
||||
let start = performance.now()
|
||||
let currentTickLength = 0
|
||||
|
@ -78,8 +79,6 @@ function FPS() {
|
|||
// of frames that we've seen since the last time we rendered,
|
||||
// and the actual time since the last render.
|
||||
function loop() {
|
||||
if (cancelled) return
|
||||
|
||||
// Count the frame
|
||||
framesInCurrentTick++
|
||||
|
||||
|
@ -111,15 +110,15 @@ function FPS() {
|
|||
start = performance.now()
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
raf = editor.timers.requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
loop()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [showFps])
|
||||
}, [showFps, editor])
|
||||
|
||||
if (!showFps) return null
|
||||
|
||||
|
|
|
@ -51,8 +51,8 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
|
|||
const rInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => rInput.current?.focus())
|
||||
}, [])
|
||||
editor.timers.requestAnimationFrame(() => rInput.current?.focus())
|
||||
}, [editor])
|
||||
|
||||
const rInitialValue = useRef(selectedShape.props.url)
|
||||
|
||||
|
|
|
@ -69,7 +69,10 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
|
|||
// has not yet entered a valid URL.
|
||||
setShowError(false)
|
||||
clearTimeout(rShowErrorTimeout.current)
|
||||
rShowErrorTimeout.current = setTimeout(() => setShowError(!embedInfo), 320)
|
||||
rShowErrorTimeout.current = editor.timers.setTimeout(
|
||||
() => setShowError(!embedInfo),
|
||||
320
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{url === '' ? (
|
||||
|
|
|
@ -33,7 +33,7 @@ export function DefaultMinimap() {
|
|||
origin: 'minimap',
|
||||
willCrashApp: false,
|
||||
})
|
||||
setTimeout(() => {
|
||||
editor.timers.setTimeout(() => {
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
@ -188,11 +188,11 @@ export function DefaultMinimap() {
|
|||
React.useEffect(() => {
|
||||
// need to wait a tick for next theme css to be applied
|
||||
// otherwise the minimap will render with the wrong colors
|
||||
setTimeout(() => {
|
||||
editor.timers.setTimeout(() => {
|
||||
minimapRef.current?.updateColors()
|
||||
minimapRef.current?.render()
|
||||
})
|
||||
}, [isDarkMode])
|
||||
}, [isDarkMode, editor])
|
||||
|
||||
return (
|
||||
<div className="tlui-minimap">
|
||||
|
|
|
@ -94,7 +94,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
|
|||
// Scroll the current page into view when the menu opens / when current page changes
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
const elm = document.querySelector(
|
||||
`[data-testid="page-menu-item-${currentPageId}"]`
|
||||
) as HTMLDivElement
|
||||
|
@ -118,7 +118,7 @@ export const DefaultPageMenu = memo(function DefaultPageMenu() {
|
|||
}
|
||||
}
|
||||
})
|
||||
}, [ITEM_HEIGHT, currentPageId, isOpen])
|
||||
}, [ITEM_HEIGHT, currentPageId, isOpen, editor])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as T from '@radix-ui/react-toast'
|
||||
import { useEditor } from '@tldraw/editor'
|
||||
import * as React from 'react'
|
||||
import { AlertSeverity, TLUiToast, useToasts } from '../context/toasts'
|
||||
import { useTranslation } from '../hooks/useTranslation/useTranslation'
|
||||
|
@ -94,26 +95,25 @@ function _Toasts() {
|
|||
export const Toasts = React.memo(_Toasts)
|
||||
|
||||
export function ToastViewport() {
|
||||
const editor = useEditor()
|
||||
const { toasts } = useToasts()
|
||||
|
||||
const [hasToasts, setHasToasts] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
let timeoutId = -1
|
||||
if (toasts.length) {
|
||||
setHasToasts(true)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setHasToasts(false)
|
||||
}
|
||||
timeoutId = editor.timers.setTimeout(() => {
|
||||
setHasToasts(false)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [toasts.length, setHasToasts])
|
||||
}, [toasts.length, setHasToasts, editor])
|
||||
|
||||
if (!hasToasts) return null
|
||||
|
||||
|
|
|
@ -74,14 +74,14 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
|
|||
setIsFocused(true)
|
||||
const elm = e.currentTarget as HTMLInputElement
|
||||
rCurrentValue.current = elm.value
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
if (autoSelect) {
|
||||
elm.select()
|
||||
}
|
||||
})
|
||||
onFocus?.()
|
||||
},
|
||||
[autoSelect, onFocus]
|
||||
[autoSelect, onFocus, editor.timers]
|
||||
)
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
|
@ -134,7 +134,7 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
|
|||
visualViewport.addEventListener('resize', onViewportChange)
|
||||
visualViewport.addEventListener('scroll', onViewportChange)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
rInputRef.current?.scrollIntoView({ block: 'center' })
|
||||
})
|
||||
|
||||
|
|
|
@ -1216,7 +1216,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
onSelect(source) {
|
||||
// this needs to be deferred because it causes the menu
|
||||
// UI to unmount which puts us in a dodgy state
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
editor.batch(() => {
|
||||
trackEvent('toggle-focus-mode', { source })
|
||||
clearDialogs()
|
||||
|
|
|
@ -25,7 +25,7 @@ export function pasteTldrawContent(editor: Editor, clipboard: TLContent, point?:
|
|||
) {
|
||||
// Creates a 'puff' to show a paste has happened.
|
||||
editor.updateInstanceState({ isChangingStyle: true })
|
||||
setTimeout(() => {
|
||||
editor.timers.setTimeout(() => {
|
||||
editor.updateInstanceState({ isChangingStyle: false })
|
||||
}, 150)
|
||||
}
|
||||
|
|
|
@ -650,7 +650,7 @@ export function useNativeClipboardEvents() {
|
|||
if (e.button === 1) {
|
||||
// middle mouse button
|
||||
disablingMiddleClickPaste = true
|
||||
requestAnimationFrame(() => {
|
||||
editor.timers.requestAnimationFrame(() => {
|
||||
disablingMiddleClickPaste = false
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,8 +11,8 @@ import { TLExportType } from './exportAs'
|
|||
|
||||
/** @public */
|
||||
export async function getSvgAsImage(
|
||||
editor: Editor,
|
||||
svgString: string,
|
||||
isSafari: boolean,
|
||||
options: {
|
||||
type: 'png' | 'jpeg' | 'webp'
|
||||
quality: number
|
||||
|
@ -42,8 +42,8 @@ export async function getSvgAsImage(
|
|||
// actually loaded. just waiting around a while is brittle, but
|
||||
// there doesn't seem to be any better solution for now :( see
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=219770
|
||||
if (isSafari) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
if (editor.environment.isSafari) {
|
||||
await new Promise((resolve) => editor.timers.setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas') as HTMLCanvasElement
|
||||
|
@ -157,7 +157,7 @@ export async function exportToBlob({
|
|||
case 'webp': {
|
||||
const svgResult = await getSvgString(editor, ids, opts)
|
||||
if (!svgResult) throw new Error('Could not construct image.')
|
||||
const image = await getSvgAsImage(svgResult.svg, editor.environment.isSafari, {
|
||||
const image = await getSvgAsImage(editor, svgResult.svg, {
|
||||
type: format,
|
||||
quality: 1,
|
||||
scale: 2,
|
||||
|
|
|
@ -20,7 +20,6 @@ import {
|
|||
TLSocketServerSentEvent,
|
||||
getTlsyncProtocolVersion,
|
||||
} from './protocol'
|
||||
import './requestAnimationFrame.polyfill'
|
||||
|
||||
type SubscribingFn<T> = (cb: (val: T) => void) => () => void
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
globalThis.requestAnimationFrame =
|
||||
globalThis.requestAnimationFrame ||
|
||||
function requestAnimationFrame(cb) {
|
||||
return setTimeout(cb, 1000 / 60)
|
||||
}
|
||||
|
||||
export {}
|
|
@ -363,6 +363,18 @@ export function throttle<T extends (...args: any) => any>(func: T, limit: number
|
|||
// @internal
|
||||
export function throttleToNextFrame(fn: () => void): () => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export class Timers {
|
||||
// (undocumented)
|
||||
dispose(): void;
|
||||
// (undocumented)
|
||||
requestAnimationFrame(callback: FrameRequestCallback): number;
|
||||
// (undocumented)
|
||||
setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number;
|
||||
// (undocumented)
|
||||
setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number;
|
||||
}
|
||||
|
||||
// @internal (undocumented)
|
||||
export function validateIndexKey(key: string): asserts key is IndexKey;
|
||||
|
||||
|
|
|
@ -70,6 +70,7 @@ export {
|
|||
setInSessionStorage,
|
||||
} from './lib/storage'
|
||||
export { fpsThrottle, throttleToNextFrame } from './lib/throttle'
|
||||
export { Timers } from './lib/timers'
|
||||
export type { Expand, RecursivePartial, Required } from './lib/types'
|
||||
export {
|
||||
STRUCTURED_CLONE_OBJECT_PROTOTYPE,
|
||||
|
|
38
packages/utils/src/lib/timers.ts
Normal file
38
packages/utils/src/lib/timers.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/** @public */
|
||||
export class Timers {
|
||||
private timeouts: number[] = []
|
||||
private intervals: number[] = []
|
||||
private rafs: number[] = []
|
||||
|
||||
/** @public */
|
||||
setTimeout(handler: TimerHandler, timeout?: number, ...args: any[]): number {
|
||||
const id = window.setTimeout(handler, timeout, args)
|
||||
this.timeouts.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** @public */
|
||||
setInterval(handler: TimerHandler, timeout?: number, ...args: any[]): number {
|
||||
const id = window.setInterval(handler, timeout, args)
|
||||
this.intervals.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** @public */
|
||||
requestAnimationFrame(callback: FrameRequestCallback): number {
|
||||
const id = window.requestAnimationFrame(callback)
|
||||
this.rafs.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
/** @public */
|
||||
dispose() {
|
||||
this.timeouts.forEach((id) => clearTimeout(id))
|
||||
this.intervals.forEach((id) => clearInterval(id))
|
||||
this.rafs.forEach((id) => cancelAnimationFrame(id))
|
||||
|
||||
this.timeouts.length = 0
|
||||
this.intervals.length = 0
|
||||
this.rafs.length = 0
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue