From f597817b657f400b50d3dcf6e08666bf63bd7e88 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 14 Nov 2025 22:01:05 +0300 Subject: [PATCH] Avoid per-layer frame delay when processing canvas layer updates: * Add `RenderingState.{scheduleOnLayerUpdate, cancelOnLayerUpdate}` as replacement for `Debouncer` + listening for `syncUpdate` event. * Add `useLayerDebouncedStore()` hook as more flexible way to debounce and update with the canvas layer. * Fix link rendering lagging behind when moving elements. --- CHANGELOG.md | 3 + examples/styleCustomization.tsx | 7 +- src/coreUtils/scheduler.ts | 2 +- src/diagram/elementLayer.tsx | 59 ++++++------ src/diagram/linkLayer.tsx | 75 ++++++++------- src/diagram/paperArea.tsx | 46 ++++----- src/diagram/renderingState.ts | 94 +++++++++++++++++-- src/editor/overlayController.tsx | 9 +- src/widgets/classTree/classTree.tsx | 3 - src/widgets/connectionsMenu.tsx | 9 +- src/widgets/halo.tsx | 9 +- src/widgets/linkAction.tsx | 6 +- src/widgets/selection.tsx | 6 +- src/widgets/selectionAction.tsx | 13 ++- .../authoredRelationOverlay.tsx | 18 ++-- .../visualAuthoring/visualAuthoring.tsx | 11 ++- src/workspace.ts | 4 +- 17 files changed, 239 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81af3b9a..b28009c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p * Subscribe to canvas `resize` event to track viewport size; * Subscribe to `changeCells` event from `DiagramModel` to track graph content changes. - Add `TemplateProps.onlySelected` flag to use in the element templates to track if the element is the only one selected without performance penalty. +- Avoid per-layer frame delay when processing canvas layer updates without calling `RenderingState.syncUpdate()`: + * Add `useLayerDebouncedStore()` hook as more flexible way to debounce and update with the canvas layer. - Avoid eager link type creation for relation links, only create and fetch them on first render. #### 💅 Polish @@ -62,6 +64,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Deprecate and hide by default "Edit" and "Delete" action buttons in `StandardEntity` expanded state (can be re-enabled by setting `showActions` prop to `true`). #### 🐛 Fixed +- Fix link rendering lagging behind when moving elements. - Fix `RdfDataProvider.links()` returning empty results when called with `linkTypeIds` parameter. - Fix `HaloLink` and visual authoring link path highlight being rendered on top on elements by placing it onto `overLinkGeometry` widget layer instead. - Fix `HaloLink` link path highlighting not updating on link re-route. diff --git a/examples/styleCustomization.tsx b/examples/styleCustomization.tsx index 0ebb9db7..30742687 100644 --- a/examples/styleCustomization.tsx +++ b/examples/styleCustomization.tsx @@ -76,12 +76,13 @@ function StyleCustomizationExample() { } function BookDecorations() { - const {model} = Reactodia.useCanvas(); + const {canvas, model} = Reactodia.useCanvas(); // Update decorations when graph content changes Reactodia.useSyncStore( - Reactodia.useFrameDebouncedStore( - Reactodia.useEventStore(model.events, 'changeCells') + Reactodia.useLayerDebouncedStore( + Reactodia.useEventStore(model.events, 'changeCells'), + canvas.renderingState ), () => model.cellsVersion ); diff --git a/src/coreUtils/scheduler.ts b/src/coreUtils/scheduler.ts index 1bc3dc2f..041a69eb 100644 --- a/src/coreUtils/scheduler.ts +++ b/src/coreUtils/scheduler.ts @@ -33,7 +33,7 @@ export class Debouncer { } private schedule() { - if (typeof this.scheduled === 'undefined') { + if (this.scheduled === undefined) { if (this.timeout === 'frame') { this.scheduled = requestAnimationFrame(this.runSynchronously); } else { diff --git a/src/diagram/elementLayer.tsx b/src/diagram/elementLayer.tsx index 2cd474ad..01610ea1 100644 --- a/src/diagram/elementLayer.tsx +++ b/src/diagram/elementLayer.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { createPortal, flushSync } from 'react-dom'; import { EventObserver } from '../coreUtils/events'; -import { Debouncer } from '../coreUtils/scheduler'; import { ElementTemplate, TemplateProps } from './customization'; @@ -60,11 +59,9 @@ export class ElementLayer extends React.Component { requests: new Map(), forAll: RedrawFlags.None, }; - private delayedRedraw = new Debouncer(); private readonly memoizedElements = new WeakMap(); private sizeRequests = new Map(); - private delayedUpdateSizes = new Debouncer(); constructor(props: ElementLayerProps) { super(props); @@ -178,21 +175,18 @@ export class ElementLayer extends React.Component { this.listener.listen(renderingState.shared.events, 'changeHighlight', () => { this.requestRedrawAll(RedrawFlags.RecomputeBlurred); }); - this.listener.listen(renderingState.events, 'syncUpdate', ({layer}) => { - flushSync(() => { - if (layer === RenderingLayer.Element) { - this.delayedRedraw.runSynchronously(); - } else if (layer === RenderingLayer.ElementSize) { - this.delayedUpdateSizes.runSynchronously(); - } - }); - }); } componentWillUnmount() { this.listener.stopListening(); - this.delayedRedraw.dispose(); - this.delayedUpdateSizes.dispose(); + this.props.renderingState.cancelOnLayerUpdate( + RenderingLayer.Element, + this.redrawElements + ); + this.props.renderingState.cancelOnLayerUpdate( + RenderingLayer.ElementSize, + this.recomputeQueuedSizes + ); } private requestRedraw = (element: Element, request: RedrawFlags) => { @@ -203,12 +197,18 @@ export class ElementLayer extends React.Component { } const existing = this.redrawBatch.requests.get(element.id) || RedrawFlags.None; this.redrawBatch.requests.set(element.id, existing | request); - this.delayedRedraw.call(this.redrawElements); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.Element, + this.redrawElements + ); }; private requestRedrawAll(request: RedrawFlags) { this.redrawBatch.forAll |= request; - this.delayedRedraw.call(this.redrawElements); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.Element, + this.redrawElements + ); } private redrawElements = () => { @@ -217,21 +217,26 @@ export class ElementLayer extends React.Component { forAll: RedrawFlags.None, requests: new Map(), }; - this.setState((state, props) => ({ - version: committedBatch.forAll === RedrawFlags.Discard - ? (state.version + 1) : state.version, - elementStates: applyRedrawRequests( - props.model, - props.renderingState.shared, - committedBatch, - state.elementStates - ) - })); + flushSync(() => { + this.setState((state, props) => ({ + version: committedBatch.forAll === RedrawFlags.Discard + ? (state.version + 1) : state.version, + elementStates: applyRedrawRequests( + props.model, + props.renderingState.shared, + committedBatch, + state.elementStates + ) + })); + }); }; private requestSizeUpdate = (element: Element, node: HTMLDivElement) => { this.sizeRequests.set(element.id, {element, node}); - this.delayedUpdateSizes.call(this.recomputeQueuedSizes); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.ElementSize, + this.recomputeQueuedSizes + ); }; private recomputeQueuedSizes = () => { diff --git a/src/diagram/linkLayer.tsx b/src/diagram/linkLayer.tsx index 6c5a3d5a..2c573ffe 100644 --- a/src/diagram/linkLayer.tsx +++ b/src/diagram/linkLayer.tsx @@ -1,9 +1,9 @@ import cx from 'clsx'; import * as React from 'react'; -import { createPortal } from 'react-dom'; +import { createPortal, flushSync } from 'react-dom'; import { EventObserver } from '../coreUtils/events'; -import { Debouncer } from '../coreUtils/scheduler'; +import { useEventStore, useSyncStore } from '../coreUtils/hooks'; import { useCanvas } from './canvasApi'; import { restoreCapturedLinkGeometry } from './commands'; @@ -15,7 +15,9 @@ import { } from './geometry'; import { DiagramModel } from './model'; import { type PaperTransform, HtmlPaperLayer } from './paper'; -import { MutableRenderingState, RenderingLayer } from './renderingState'; +import { + type MutableRenderingState, RenderingLayer, useLayerDebouncedStore, +} from './renderingState'; export interface LinkLayerProps { model: DiagramModel; @@ -44,7 +46,6 @@ const CLASS_NAME = 'reactodia-link-layer'; export class LinkLayer extends React.Component { private readonly listener = new EventObserver(); - private readonly delayedUpdate = new Debouncer(); private providedContext: LinkLayerContext; @@ -53,7 +54,6 @@ export class LinkLayer extends React.Component { private scheduledToUpdate = new Set(); private labelMeasureRequests = new Set(); - private delayedMeasureLabels = new Debouncer(); private readonly memoizedLinks = new WeakMap(); @@ -135,18 +135,6 @@ export class LinkLayer extends React.Component { updateChangedRoutes(newRoutes, previous); updateChangedRoutes(previous, newRoutes); }); - this.listener.listen(renderingState.events, 'syncUpdate', ({layer}) => { - switch (layer) { - case RenderingLayer.Link: { - this.delayedUpdate.runSynchronously(); - break; - } - case RenderingLayer.LinkLabel: { - this.delayedMeasureLabels.runSynchronously(); - break; - } - } - }); } shouldComponentUpdate(nextProps: LinkLayerProps, nextState: LinkLayerState): boolean { @@ -159,8 +147,14 @@ export class LinkLayer extends React.Component { componentWillUnmount() { this.listener.stopListening(); - this.delayedUpdate.dispose(); - this.delayedMeasureLabels.dispose(); + this.props.renderingState.cancelOnLayerUpdate( + RenderingLayer.Link, + this.performUpdate + ); + this.props.renderingState.cancelOnLayerUpdate( + RenderingLayer.LinkLabel, + this.measureLabels + ); } private scheduleUpdateAll = () => { @@ -168,14 +162,20 @@ export class LinkLayer extends React.Component { this.updateState = UpdateRequest.All; this.scheduledToUpdate = new Set(); } - this.delayedUpdate.call(this.performUpdate); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.Link, + this.performUpdate + ); }; private scheduleUpdateLink(linkId: string) { if (this.updateState === UpdateRequest.Partial) { this.scheduledToUpdate.add(linkId); } - this.delayedUpdate.call(this.performUpdate); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.Link, + this.performUpdate + ); } private popShouldUpdatePredicate(): (model: Link) => boolean { @@ -189,7 +189,10 @@ export class LinkLayer extends React.Component { private scheduleLabelMeasure = (label: MeasurableLabel) => { this.labelMeasureRequests.add(label); - this.delayedMeasureLabels.call(this.measureLabels); + this.props.renderingState.scheduleOnLayerUpdate( + RenderingLayer.LinkLabel, + this.measureLabels + ); }; private clearLabelMeasure = (label: MeasurableLabel) => { @@ -218,8 +221,10 @@ export class LinkLayer extends React.Component { }; private performUpdate = () => { - this.setState({ - shouldUpdateLink: this.popShouldUpdatePredicate(), + flushSync(() => { + this.setState({ + shouldUpdateLink: this.popShouldUpdatePredicate(), + }); }); }; @@ -682,20 +687,14 @@ function LinkMarkersInner(props: { }) { const {model, renderingState} = props; - const [cellsVersion, setCellsVersion] = React.useState(model.cellsVersion); - - React.useEffect(() => { - const listener = new EventObserver(); - const delayedUpdate = new Debouncer(); - - listener.listen(renderingState.events, 'syncUpdate', ({layer}) => { - if (layer !== RenderingLayer.Link) { return; } - delayedUpdate.runSynchronously(); - }); - listener.listen(renderingState.events, 'changeLinkTemplates', () => { - delayedUpdate.call(() => setCellsVersion(model.cellsVersion)); - }); - }, []); + useSyncStore( + useLayerDebouncedStore( + useEventStore(renderingState.events, 'changeLinkTemplates'), + renderingState, + RenderingLayer.Link + ), + () => model.cellsVersion + ); const sourceMarkers = new Set(); const targetMarkers = new Set(); diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index 7defd3c7..da942ecd 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -1,10 +1,11 @@ import cx from 'clsx'; import * as React from 'react'; +import { flushSync } from 'react-dom'; import { delay } from '../coreUtils/async'; import type { ColorSchemeApi } from '../coreUtils/colorScheme'; import { EventObserver, Events, EventSource } from '../coreUtils/events'; -import { Debouncer, animateInterval, easeInOutBezier } from '../coreUtils/scheduler'; +import { animateInterval, easeInOutBezier } from '../coreUtils/scheduler'; import { CanvasContext, CanvasApi, CanvasEvents, CanvasMetrics, CanvasAreaMetrics, @@ -108,7 +109,6 @@ export class PaperArea extends React.Component implements private movingState: PointerMoveState | undefined; - private delayedPaperAdjust = new Debouncer(); private scrollBeforeUpdate: undefined | { left: number; top: number; @@ -281,7 +281,12 @@ export class PaperArea extends React.Component implements this.adjustPaper(() => void this.centerTo()); const {model, renderingState} = this.props; - const delayedAdjust = () => this.delayedPaperAdjust.call(this.adjustPaper); + const delayedAdjust = () => { + renderingState.scheduleOnLayerUpdate( + RenderingLayer.PaperArea, + this.adjustPaper + ); + }; this.listener.listen(model.events, 'changeCells', delayedAdjust); this.listener.listen(model.events, 'elementEvent', ({data}) => { if (data.changePosition) { @@ -294,10 +299,6 @@ export class PaperArea extends React.Component implements } }); this.listener.listen(renderingState.events, 'changeElementSize', delayedAdjust); - this.listener.listen(renderingState.events, 'syncUpdate', ({layer}) => { - if (layer !== RenderingLayer.PaperArea) { return; } - this.delayedPaperAdjust.runSynchronously(); - }); this.listener.listen(renderingState.shared.events, 'findCanvas', e => { e.canvases.push(this); }); @@ -330,7 +331,10 @@ export class PaperArea extends React.Component implements componentWillUnmount() { this.stopListeningToPointerMove(); this.listener.stopListening(); - this.delayedPaperAdjust.dispose(); + this.renderingState.cancelOnLayerUpdate( + RenderingLayer.PaperArea, + this.adjustPaper + ); this.area.removeEventListener('dragover', this.onDragOver); this.area.removeEventListener('drop', this.onDragDrop); this.area.removeEventListener('scroll', this.onScroll); @@ -535,7 +539,6 @@ export class PaperArea extends React.Component implements y: elementY + y - pointerY, }); this.source.trigger('pointerMove', {source: this, sourceEvent: e, target, panning}); - renderingState.syncUpdate(); } } else if (target instanceof Link) { e.preventDefault(); @@ -548,7 +551,6 @@ export class PaperArea extends React.Component implements const location = this.metrics.pageToPaperCoords(e.pageX, e.pageY); target.moveTo(location); this.source.trigger('pointerMove', {source: this, sourceEvent: e, target, panning}); - renderingState.syncUpdate(); } }; @@ -958,18 +960,20 @@ export class PaperArea extends React.Component implements private changeGraphAnimationCount(change: number, newDuration?: number) { const beforeAnimating = this.isAnimatingGraph(); - this.setState( - previous => ({ - cssAnimations: previous.cssAnimations + change, - cssAnimationDuration: newDuration ?? previous.cssAnimationDuration, - }), - () => { - const afterAnimating = this.isAnimatingGraph(); - if (afterAnimating !== beforeAnimating) { - this.source.trigger('changeAnimatingGraph', {source: this, previous: beforeAnimating}); + flushSync(() => { + this.setState( + previous => ({ + cssAnimations: previous.cssAnimations + change, + cssAnimationDuration: newDuration ?? previous.cssAnimationDuration, + }), + () => { + const afterAnimating = this.isAnimatingGraph(); + if (afterAnimating !== beforeAnimating) { + this.source.trigger('changeAnimatingGraph', {source: this, previous: beforeAnimating}); + } } - } - ); + ); + }); } private get viewportState(): ViewportState { diff --git a/src/diagram/renderingState.ts b/src/diagram/renderingState.ts index cb12e1a2..9d33acd8 100644 --- a/src/diagram/renderingState.ts +++ b/src/diagram/renderingState.ts @@ -3,6 +3,7 @@ import * as React from 'react'; import { multimapAdd, multimapDelete } from '../coreUtils/collections'; import { Events, EventObserver, EventSource, PropertyChange } from '../coreUtils/events'; +import { type SyncStore } from '../coreUtils/hooks'; import { type HotkeyAst, formatHotkey, sameHotkeyAst, hashHotkeyAst, eventToHotkeyAst, } from '../coreUtils/hotkey'; @@ -78,6 +79,9 @@ export interface RenderingStateEvents { * The layers are organized in such way that changes from an earlier layer * only affect rendering on the later layers. This way the full rendering * could be done by rendering on each layer in order. + * + * Each layer index should be considered unspecified, only relative + * layer order is guaranteed to not change. */ export enum RenderingLayer { /** @@ -148,6 +152,24 @@ export interface RenderingState extends SizeProvider { * ``` */ syncUpdate(): void; + /** + * Schedules a callback until next canvas {@link RenderingLayer} update. + * + * If the same `callback` is scheduled on the same layer, it will run only + * once on the layer update. + * + * @see {@link RenderingState.cancelOnLayerUpdate} + * @see {@link useLayerDebouncedStore} + */ + scheduleOnLayerUpdate(layer: RenderingLayer, callback: () => void): void; + /** + * Cancels the previously scheduled callback via + * {@link RenderingState.scheduleOnLayerUpdate}. + * + * If the `callback` is not currently scheduled on the specified `layer`, + * nothing will be done. + */ + cancelOnLayerUpdate(layer: RenderingLayer, callback: () => void): void; /** * Returns computed element size in paper coordinates. */ @@ -179,6 +201,9 @@ export class MutableRenderingState implements RenderingState { private readonly source = new EventSource(); readonly events: Events = this.source; + private readonly scheduledByLayer = new Map void>>(); + private readonly layerUpdater = new Debouncer(); + private readonly model: DiagramModel; private readonly resolveElementTemplate: ElementTemplateResolver; private readonly resolveLinkTemplate: LinkTemplateResolver; @@ -195,7 +220,6 @@ export class MutableRenderingState implements RenderingState { private readonly linkMarkerIndex = new WeakMap(); private static nextLinkMarkerIndex = 1; - private readonly delayedUpdateRoutings = new Debouncer(); private routings: RoutedLinks = new Map(); private readonly hotkeyHandlers = new HashMap void>>( @@ -233,7 +257,7 @@ export class MutableRenderingState implements RenderingState { const routings = this.routings; this.routings = new Map(); - this.delayedUpdateRoutings.dispose(); + this.cancelOnLayerUpdate(RenderingLayer.LinkRoutes, this.updateRoutings); this.source.trigger('changeLinkTemplates', {source: this}); this.source.trigger('changeRoutings', {source: this, previous: routings}); @@ -241,11 +265,6 @@ export class MutableRenderingState implements RenderingState { this.listener.listen(this.events, 'changeElementSize', () => { this.scheduleUpdateRoutings(); }); - this.listener.listen(this.events, 'syncUpdate', ({layer}) => { - if (layer === RenderingLayer.LinkRoutes) { - this.delayedUpdateRoutings.runSynchronously(); - } - }); this.updateRoutings(); } @@ -253,14 +272,46 @@ export class MutableRenderingState implements RenderingState { /** @hidden */ dispose() { this.listener.stopListening(); - this.delayedUpdateRoutings.dispose(); + this.layerUpdater.dispose(); + this.cancelOnLayerUpdate(RenderingLayer.LinkRoutes, this.updateRoutings); } syncUpdate() { + this.layerUpdater.dispose(); + this.runLayerUpdate(); + } + + scheduleOnLayerUpdate(layer: RenderingLayer, callback: () => void): void { + multimapAdd(this.scheduledByLayer, layer, callback); + this.layerUpdater.call(this.runLayerUpdate); + } + + cancelOnLayerUpdate(layer: RenderingLayer, callback: () => void): void { + const callbackSet = this.scheduledByLayer.get(layer); + if (callbackSet) { + callbackSet.delete(callback); + } + } + + private runLayerUpdate = () => { + const toRun = new Set<() => void>(); for (let layer = FIRST_LAYER; layer <= LAST_LAYER; layer++) { + const callbackSet = this.scheduledByLayer.get(layer); + if (callbackSet && callbackSet.size > 0) { + for (const callback of callbackSet) { + toRun.add(callback); + } + callbackSet.clear(); + + for (const callback of toRun) { + callback(); + } + toRun.clear(); + } + this.source.trigger('syncUpdate', {layer}); } - } + }; ensureDecorationContainer(target: Element | Link): HTMLDivElement { let container = this.decorationContainers.get(target); @@ -385,7 +436,7 @@ export class MutableRenderingState implements RenderingState { } private scheduleUpdateRoutings() { - this.delayedUpdateRoutings.call(this.updateRoutings); + this.scheduleOnLayerUpdate(RenderingLayer.LinkRoutes, this.updateRoutings); } private updateRoutings = () => { @@ -440,3 +491,26 @@ function sameRoutedLink(a: RoutedLink, b: RoutedLink): boolean { isPolylineEqual(a.vertices, b.vertices) ); } + +/** + * Transforms event store in a way that the result store debounces the changes + * until the next time the specified canvas {@link RenderingLayer layer} updates. + * + * @category Hooks + */ +export function useLayerDebouncedStore( + subscribe: SyncStore, + renderingState: RenderingState, + layer = RenderingLayer.Overlay +): SyncStore { + return React.useCallback(onChange => { + const onUpdate = () => onChange(); + const dispose = subscribe(() => { + renderingState.scheduleOnLayerUpdate(layer, onUpdate); + }); + return () => { + renderingState.cancelOnLayerUpdate(layer, onUpdate); + dispose(); + }; + }, [subscribe, renderingState, layer]); +} diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index 6e84b759..171d42f9 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { delay } from '../coreUtils/async'; import { Events, EventObserver, EventSource, PropertyChange } from '../coreUtils/events'; import { - useEventStore, useFrameDebouncedStore, useObservedProperty, useSyncStoreWithComparator, + useEventStore, useObservedProperty, useSyncStoreWithComparator, } from '../coreUtils/hooks'; import type { Translation } from '../coreUtils/i18n'; @@ -12,7 +12,7 @@ import { Element, Link, LinkVertex } from '../diagram/elements'; import { Size, Vector } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; import { CanvasPlaceAt } from '../diagram/placeLayer'; -import type { MutableRenderingState } from '../diagram/renderingState'; +import { type MutableRenderingState, useLayerDebouncedStore } from '../diagram/renderingState'; import { SharedCanvasState } from '../diagram/sharedCanvasState'; import { Spinner, SpinnerProps } from '../diagram/spinner'; @@ -565,8 +565,9 @@ function ViewportDialog(props: DialogProps) { function useViewportSize() { const {canvas} = useCanvas(); - const resizeStore = useFrameDebouncedStore( - useEventStore(canvas.events, 'resize') + const resizeStore = useLayerDebouncedStore( + useEventStore(canvas.events, 'resize'), + canvas.renderingState ); const size = useSyncStoreWithComparator( resizeStore, diff --git a/src/widgets/classTree/classTree.tsx b/src/widgets/classTree/classTree.tsx index 0cbc20f1..274c7ac0 100644 --- a/src/widgets/classTree/classTree.tsx +++ b/src/widgets/classTree/classTree.tsx @@ -6,7 +6,6 @@ import { multimapAdd } from '../../coreUtils/collections'; import { EventObserver } from '../../coreUtils/events'; import { useObservedProperty } from '../../coreUtils/hooks'; import { Translation } from '../../coreUtils/i18n'; -import { Debouncer } from '../../coreUtils/scheduler'; import { ElementTypeIri, ElementTypeModel, ElementTypeGraph, SubtypeEdge } from '../../data/model'; import { DataProvider } from '../../data/dataProvider'; @@ -137,7 +136,6 @@ const EMPTY_CREATABLE_TYPES: ReadonlyMap = new Map(); class ClassTreeInner extends React.Component { private readonly listener = new EventObserver(); private readonly searchListener = new EventObserver(); - private readonly delayedClassUpdate = new Debouncer(); private loadClassesOperation = new AbortController(); private refreshOperation = new AbortController(); @@ -268,7 +266,6 @@ class ClassTreeInner extends React.Component { componentWillUnmount() { this.listener.stopListening(); this.searchListener.stopListening(); - this.delayedClassUpdate.dispose(); this.loadClassesOperation.abort(); this.refreshOperation.abort(); this.createElementCancellation.abort(); diff --git a/src/widgets/connectionsMenu.tsx b/src/widgets/connectionsMenu.tsx index e371d7e0..10a8098f 100644 --- a/src/widgets/connectionsMenu.tsx +++ b/src/widgets/connectionsMenu.tsx @@ -2,7 +2,6 @@ import cx from 'clsx'; import * as React from 'react'; import { EventObserver } from '../coreUtils/events'; -import { Debouncer } from '../coreUtils/scheduler'; import { TranslatedText } from '../coreUtils/i18n'; import { ElementModel, ElementIri, LinkTypeIri, LinkTypeModel } from '../data/model'; @@ -13,6 +12,7 @@ import { changeLinkTypeVisibility, placeElementsAroundTarget } from '../diagram/ import { Element, VoidElement } from '../diagram/elements'; import { getContentFittingBox } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; +import { RenderingLayer } from '../diagram/renderingState'; import { HtmlSpinner } from '../diagram/spinner'; import { BuiltinDialogType } from '../editor/builtinDialogType'; @@ -330,7 +330,6 @@ class ConnectionsMenuInner extends React.Component(); @@ -358,7 +357,8 @@ class ConnectionsMenuInner extends React.Component this.forceUpdate(); private scheduleUpdateAll = () => { - this.delayedUpdateAll.call(this.updateAll); + const {canvas} = this.props; + canvas.renderingState.scheduleOnLayerUpdate(RenderingLayer.Overlay, this.updateAll); }; componentDidMount() { @@ -397,9 +397,10 @@ class ConnectionsMenuInner extends React.Component { this.targetListener.listenAny(element.events, this.onElementEvent); this.targetListener.listen(canvas.renderingState.events, 'changeElementSize', e => { if (e.source === element) { - this.forceUpdate(); + flushSync(() => { + this.forceUpdate(); + }); } }); } @@ -134,7 +137,9 @@ class HaloInner extends React.Component { private onElementEvent: AnyListener = data => { if (data.changePosition) { - this.forceUpdate(); + flushSync(() => { + this.forceUpdate(); + }); } }; diff --git a/src/widgets/linkAction.tsx b/src/widgets/linkAction.tsx index 8d84ba07..c5851f70 100644 --- a/src/widgets/linkAction.tsx +++ b/src/widgets/linkAction.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { mapAbortedToNull } from '../coreUtils/async'; import { - useEventStore, useObservedProperty, useFrameDebouncedStore, useSyncStore, + useEventStore, useObservedProperty, useSyncStore, } from '../coreUtils/hooks'; import type { HotkeyString } from '../coreUtils/hotkey'; @@ -13,6 +13,7 @@ import { useCanvas } from '../diagram/canvasApi'; import { useCanvasHotkey } from '../diagram/canvasHotkey'; import { Link } from '../diagram/elements'; import { GraphStructure } from '../diagram/model'; +import { useLayerDebouncedStore } from '../diagram/renderingState'; import { HtmlSpinner } from '../diagram/spinner'; import { AnnotationLink } from '../editor/annotationCells'; @@ -324,10 +325,11 @@ function useCanModifyLink( graph: GraphStructure, editor: EditorController ): MetadataCanModifyRelation | undefined { + const {canvas} = useCanvas(); const [canModify, setCanModify] = React.useState(); const authoringStateStore = useEventStore(editor.events, 'changeAuthoringState'); - const debouncedStateStore = useFrameDebouncedStore(authoringStateStore); + const debouncedStateStore = useLayerDebouncedStore(authoringStateStore, canvas.renderingState); const authoringState = useSyncStore(debouncedStateStore, () => editor.authoringState); React.useEffect(() => { diff --git a/src/widgets/selection.tsx b/src/widgets/selection.tsx index b633c3b0..631fc3b5 100644 --- a/src/widgets/selection.tsx +++ b/src/widgets/selection.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { shallowArrayEqual } from '../coreUtils/collections'; import { EventObserver } from '../coreUtils/events'; import { - SyncStore, useEventStore, useFrameDebouncedStore, useSyncStore, useSyncStoreWithComparator, + SyncStore, useEventStore, useSyncStore, useSyncStoreWithComparator, } from '../coreUtils/hooks'; import type { HotkeyString } from '../coreUtils/hotkey'; @@ -16,6 +16,7 @@ import { } from '../diagram/geometry'; import { DiagramModel } from '../diagram/model'; import { CanvasPlaceAt } from '../diagram/placeLayer'; +import { useLayerDebouncedStore } from '../diagram/renderingState'; import { SelectionActionRemove, SelectionActionZoomToFit, SelectionActionLayout, @@ -236,9 +237,8 @@ function SelectionBox(props: SelectionBoxProps) { ); const elementBoundsStore = useElementBoundsStore(model, canvas, selectedElements); - const elementBoundsDebouncedStore = useFrameDebouncedStore(elementBoundsStore); const fittingBox = useSyncStoreWithComparator( - elementBoundsDebouncedStore, + useLayerDebouncedStore(elementBoundsStore, canvas.renderingState), () => getContentFittingBox(selectedElements, [], canvas.renderingState), Rect.equals ); diff --git a/src/widgets/selectionAction.tsx b/src/widgets/selectionAction.tsx index 1ebf3ea8..769711f6 100644 --- a/src/widgets/selectionAction.tsx +++ b/src/widgets/selectionAction.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { mapAbortedToNull } from '../coreUtils/async'; import { EventObserver } from '../coreUtils/events'; import { - SyncStore, useEventStore, useFrameDebouncedStore, useObservedProperty, useSyncStore, + SyncStore, useEventStore, useObservedProperty, useSyncStore, } from '../coreUtils/hooks'; import type { HotkeyString } from '../coreUtils/hotkey'; import { TranslatedText, useTranslation } from '../coreUtils/i18n'; @@ -19,6 +19,7 @@ import { Element, Link } from '../diagram/elements'; import { getContentFittingBox } from '../diagram/geometry'; import type { DiagramModel } from '../diagram/model'; import { HtmlSpinner } from '../diagram/spinner'; +import { useLayerDebouncedStore } from '../diagram/renderingState'; import { AnnotationElement } from '../editor/annotationCells'; import { AuthoringState } from '../editor/authoringState'; @@ -338,14 +339,13 @@ export function SelectionActionExpand(props: SelectionActionExpandProps) { const elements = model.selection.filter((cell): cell is Element => cell instanceof Element); const elementExpandedStore = useElementExpandedStore(model, elements); - const debouncedExpandedStore = useFrameDebouncedStore(elementExpandedStore); const canExpand = (element: Element) => { const template = canvas.renderingState.getElementTemplate(element); return Boolean(template.supports?.[TemplateProperties.Expanded]); }; const allExpanded = useSyncStore( - debouncedExpandedStore, + useLayerDebouncedStore(elementExpandedStore, canvas.renderingState), () => elements.every(element => !canExpand(element) || element.isExpanded) ); @@ -752,6 +752,7 @@ function useCanEstablishLink( target: Element | undefined, linkType: LinkTypeIri | undefined ): boolean | undefined { + const {canvas} = useCanvas(); const [canLink, setCanLink] = React.useState(); const entityTarget = target instanceof EntityElement ? target : undefined; @@ -759,8 +760,10 @@ function useCanEstablishLink( const targetData = useSyncStore(loadDataStore, () => entityTarget?.data); const authoringStateStore = useEventStore(editor.events, 'changeAuthoringState'); - const debouncedStateStore = useFrameDebouncedStore(authoringStateStore); - const authoringState = useSyncStore(debouncedStateStore, () => editor.authoringState); + const authoringState = useSyncStore( + useLayerDebouncedStore(authoringStateStore, canvas.renderingState), + () => editor.authoringState + ); const authoringEvent = target instanceof EntityElement ? authoringState.elements.get(target.iri) : undefined; diff --git a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx index 66e408ef..95f5aa50 100644 --- a/src/widgets/visualAuthoring/authoredRelationOverlay.tsx +++ b/src/widgets/visualAuthoring/authoredRelationOverlay.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { EventObserver } from '../../coreUtils/events'; import { useObservedProperty } from '../../coreUtils/hooks'; -import { Debouncer } from '../../coreUtils/scheduler'; import { LinkKey } from '../../data/model'; import type { ValidationSeverity } from '../../data/validationProvider'; @@ -59,14 +58,18 @@ const DEFAULT_LINK_LABEL_MARGIN = 5; class LinkStateWidgetInner extends React.Component { private readonly listener = new EventObserver(); - private readonly delayedUpdate = new Debouncer(); componentDidMount() { this.listenEvents(); } componentWillUnmount() { + const {canvas} = this.props; this.listener.stopListening(); + canvas.renderingState.cancelOnLayerUpdate( + RenderingLayer.Overlay, + this.performUpdate + ); } private listenEvents() { @@ -92,15 +95,14 @@ class LinkStateWidgetInner extends React.Component { - if (layer === RenderingLayer.Overlay) { - this.delayedUpdate.runSynchronously(); - } - }); } private scheduleUpdate = () => { - this.delayedUpdate.call(this.performUpdate); + const {canvas} = this.props; + canvas.renderingState.scheduleOnLayerUpdate( + RenderingLayer.Overlay, + this.performUpdate + ); }; private performUpdate = () => { diff --git a/src/widgets/visualAuthoring/visualAuthoring.tsx b/src/widgets/visualAuthoring/visualAuthoring.tsx index 1356b57f..5c6383fe 100644 --- a/src/widgets/visualAuthoring/visualAuthoring.tsx +++ b/src/widgets/visualAuthoring/visualAuthoring.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { EventObserver } from '../../coreUtils/events'; import { - useEventStore, useFrameDebouncedStore, useObservedProperty, useSyncStore, + useEventStore, useObservedProperty, useSyncStore, } from '../../coreUtils/hooks'; -import { Debouncer } from '../../coreUtils/scheduler'; import type { ElementModel, LinkModel } from '../../data/model'; +import { useCanvas } from '../../diagram/canvasApi'; import { Link } from '../../diagram/elements'; import { Size } from '../../diagram/geometry'; +import { useLayerDebouncedStore } from '../../diagram/renderingState'; import { AuthoringState } from '../../editor/authoringState'; import { BuiltinDialogType } from '../../editor/builtinDialogType'; @@ -312,6 +313,7 @@ function EntityDecoratorsInner(props: { inlineActions: boolean; }) { const {inlineActions} = props; + const {canvas} = useCanvas(); const {model, editor} = useWorkspace(); const inAuthoringMode = useObservedProperty( @@ -320,7 +322,10 @@ function EntityDecoratorsInner(props: { () => editor.inAuthoringMode ); const cellsVersion = useSyncStore( - useFrameDebouncedStore(useEventStore(model.events, 'changeCells')), + useLayerDebouncedStore( + useEventStore(model.events, 'changeCells'), + canvas.renderingState + ), () => model.cellsVersion ); diff --git a/src/workspace.ts b/src/workspace.ts index 8ceabdfe..ad86786a 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -99,7 +99,9 @@ export { SvgPaperLayer, type SvgPaperLayerProps, } from './diagram/paper'; export { CanvasPlaceAt, type CanvasPlaceAtLayer } from './diagram/placeLayer'; -export { type RenderingState, type RenderingStateEvents, RenderingLayer } from './diagram/renderingState'; +export { + type RenderingState, type RenderingStateEvents, RenderingLayer, useLayerDebouncedStore, +} from './diagram/renderingState'; export { type SharedCanvasState, type SharedCanvasStateEvents, type CellHighlighter, type FindCanvasEvent, RenameLinkToLinkStateProvider,