perf(editor): lazy DOM update with idle state in gfx viewport (#10624)

Currently, `GfxViewportElement` hides DOM blocks outside the viewport using `display: none` to optimize performance. However, this approach presents two issues:

1. Even when hidden, all top-level blocks still undergo frequent CSS transform updates during viewport panning and zooming.
2. Hidden blocks cannot access DOM layout information, preventing `TurboRenderer` from updating the complete canvas bitmap.

To address this, this PR introduces a refactoring that divides all top-level edgeless blocks into two states: `idle` and `active`. The improvements are as follows:

1. Blocks outside the viewport are set to the `idle` state, meaning they no longer update their DOM during viewport panning or zooming. Only `active` blocks within the viewport are updated frame by frame.
2. For `idle` blocks, the hiding method switches from `display: none` to `visibility: hidden`, ensuring their layout information remains accessible to `TurboRenderer`.

[Screen Recording 2025-03-07 at 3.23.56 PM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/lEGcysB4lFTEbCwZ8jMv/4bac640b-f5b6-4b0b-904d-5899f96cf375.mov" />](https://app.graphite.dev/media/video/lEGcysB4lFTEbCwZ8jMv/4bac640b-f5b6-4b0b-904d-5899f96cf375.mov)

While this minimizes DOM updates, it introduces a trade-off: `idle` blocks retain an outdated layout state. Since their positions are updated using a lazy update strategy, their layout state remains frozen at the moment they were last moved out of the viewport:

![idle-issue.jpg](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lEGcysB4lFTEbCwZ8jMv/9c8c2150-69d4-416b-b46e-8473a7fdf339.jpg)

To resolve this, the PR serializes and stores the viewport field of the block at that moment on the `idle` block itself. This allows the correct layout, positioned in the model coordinate system, to be restored from the stored data.
This commit is contained in:
doodlewind
2025-03-08 01:38:02 +00:00
parent dc047aa1a4
commit 334912e85b
12 changed files with 176 additions and 98 deletions

View File

@@ -35,10 +35,21 @@ export function requestThrottledConnectedFrame<
}) as T;
}
function setDisplay(view: BlockComponent | null, display: 'block' | 'none') {
function setBlockState(view: BlockComponent | null, state: 'active' | 'idle') {
if (!view) return;
if (view.style.display !== display) {
view.style.display = display;
if (state === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.add('block-active');
view.dataset.blockState = 'active';
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.add('block-idle');
view.dataset.blockState = 'idle';
}
}
@@ -55,20 +66,31 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
display: block;
transform: none;
}
`;
optimizedBlocks = new Set<string>();
/* CSS for idle blocks that are hidden but maintain layout */
.block-idle {
visibility: hidden;
pointer-events: none;
will-change: transform;
contain: size layout style;
}
/* CSS for active blocks participating in viewport transformations */
.block-active {
visibility: visible;
pointer-events: auto;
}
`;
private readonly _hideOutsideBlock = () => {
if (!this.host) return;
const { host, optimizedBlocks, enableOptimization } = this;
const { host } = this;
const modelsInViewport = this.getModelsInViewport();
modelsInViewport.forEach(model => {
const view = host.std.view.getBlock(model.id);
const canOptimize = optimizedBlocks.has(model.id) && enableOptimization;
const display = canOptimize ? 'none' : 'block';
setDisplay(view, display);
setBlockState(view, 'active');
if (this._lastVisibleModels?.has(model)) {
this._lastVisibleModels!.delete(model);
@@ -77,7 +99,7 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._lastVisibleModels?.forEach(model => {
const view = host.std.view.getBlock(model.id);
setDisplay(view, 'none');
setBlockState(view, 'idle');
});
this._lastVisibleModels = modelsInViewport;
@@ -170,28 +192,25 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor viewport!: Viewport;
@property({ attribute: false })
accessor enableOptimization: boolean = false;
updateOptimizedBlocks(blockIds: string[], optimized: boolean): void {
let changed = false;
setBlocksActive(blockIds: string[]): void {
if (!this.host) return;
blockIds.forEach(id => {
if (optimized && !this.optimizedBlocks.has(id)) {
this.optimizedBlocks.add(id);
changed = true;
} else if (!optimized && this.optimizedBlocks.has(id)) {
this.optimizedBlocks.delete(id);
changed = true;
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'active');
}
});
if (changed) this._refreshViewport();
}
clearOptimizedBlocks(): void {
if (this.optimizedBlocks.size === 0) return;
this.optimizedBlocks.clear();
this._refreshViewport();
setBlocksIdle(blockIds: string[]): void {
if (!this.host) return;
blockIds.forEach(id => {
const view = this.host?.std.view.getBlock(id);
if (view) {
setBlockState(view, 'idle');
}
});
}
}

View File

@@ -24,6 +24,29 @@ export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100;
export interface ViewportRecord {
left: number;
top: number;
viewportX: number;
viewportY: number;
zoom: number;
viewScale: number;
}
export function clientToModelCoord(
viewport: ViewportRecord,
clientCoord: [number, number]
): IVec {
const { left, top, viewportX, viewportY, zoom, viewScale } = viewport;
const [clientX, clientY] = clientCoord;
const viewportInternalX = clientX - left;
const viewportInternalY = clientY - top;
const modelX = viewportX + viewportInternalX / zoom / viewScale;
const modelY = viewportY + viewportInternalY / zoom / viewScale;
return [modelX, modelY];
}
export class Viewport {
private _cachedBoundingClientRect: DOMRect | null = null;
@@ -461,8 +484,7 @@ export class Viewport {
}
toModelCoordFromClientCoord([x, y]: IVec): IVec {
const { left, top } = this;
return this.toModelCoord(x - left, y - top);
return clientToModelCoord(this, [x, y]);
}
toViewBound(bound: Bound) {
@@ -484,4 +506,26 @@ export class Viewport {
const { left, top } = this;
return [x - left, y - top];
}
serializeRecord() {
return JSON.stringify({
left: this.left,
top: this.top,
viewportX: this.viewportX,
viewportY: this.viewportY,
zoom: this.zoom,
viewScale: this.viewScale,
});
}
deserializeRecord(record?: string) {
try {
const result = JSON.parse(record || '{}') as ViewportRecord;
if (!('zoom' in result)) return null;
return result;
} catch (error) {
console.error('Failed to deserialize viewport record:', error);
return null;
}
}
}

View File

@@ -18,6 +18,10 @@ export function isGfxBlockComponent(
export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) {
if (element.dataset.blockState === 'idle') return;
const { viewport } = element.gfx;
element.dataset.viewportState = viewport.serializeRecord();
element.style.transformOrigin = '0 0';
element.style.transform = element.getCSSTransform();
}