fix(editor): edgeless can't slider with finger (#15091)

fix bug edgeless can't slider with finger 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added mobile immersive edgeless mode with dynamic chrome auto-hide and
tap-gesture controls.
  * Added a mobile zoom ruler UI for edgeless.
* **Bug Fixes**
* Improved iOS rendering/zoom by applying low-zoom survival behavior,
gesture-aware refresh deferral, and effective-DPR canvas scaling.
* Fixed iOS webview zoom/bounce and process-termination reload behavior.
  * Improved placeholder styling with theme-aware colors.
* **Chores**
  * Updated local ignore rules and iOS app build/version configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-06-16 21:19:31 +08:00
committed by GitHub
parent c51bdb74de
commit a77d89bb1a
43 changed files with 4749 additions and 273 deletions
@@ -1,11 +1,19 @@
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import { effects } from '../../effects.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
import { getPostGestureRecoveryDelay } from '../../gfx/viewport.js';
import {
GfxViewportElement,
shouldUseLowZoomBlockSurvivalMode,
} from '../../gfx/viewport-element.js';
import type { GfxBlockComponent } from '../../view/element/gfx-block-component.js';
import { TestEditorContainer } from '../test-editor.js';
import { TestLocalElement } from '../test-gfx-element.js';
import {
@@ -52,6 +60,7 @@ const commonSetup = async () => {
const gfx = editorContainer.std.get(GfxControllerIdentifier);
return {
editorContainer,
gfx,
surfaceId,
rootId,
@@ -59,6 +68,74 @@ const commonSetup = async () => {
};
};
const waitGfxViewConnected = (gfx: {
std: {
view: {
viewUpdated: {
subscribe: (
callback: (payload: {
id: string;
type: string;
method: string;
}) => void
) => { unsubscribe: () => void };
};
};
};
}) => {
return (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
if (
payload.id === id &&
payload.type === 'block' &&
payload.method === 'add'
) {
subscription.unsubscribe();
resolve();
}
});
return promise;
};
};
const getTestGfxBlockModel = (
gfx: { getElementById: (id: string) => unknown },
id: string
) => {
const model = gfx.getElementById(id) as GfxBlockElementModel | null;
if (!model) {
throw new Error(`Missing gfx model for block ${id}`);
}
return model;
};
const getTestGfxBlockView = (
gfx: { view: { get: (id: string) => unknown } },
id: string
) => {
const view = gfx.view.get(id) as GfxBlockComponent | null;
if (!view) {
throw new Error(`Missing gfx view for block ${id}`);
}
return view;
};
const getViewportChildBlockIds = (viewportElement: GfxViewportElement) =>
[...viewportElement.children].map(
child => (child as HTMLElement).dataset.blockId
);
const setBlockXYWH = (
gfx: { getElementById: (id: string) => unknown },
id: string,
xywh: SerializedXYWH
) => {
const model = getTestGfxBlockModel(gfx, id);
model.xywh = xywh;
};
describe('gfx element view basic', () => {
test('view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
@@ -91,24 +168,10 @@ describe('gfx element view basic', () => {
test('query gfx block view should work', async () => {
const { gfx, surfaceId, rootId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const waitGfxViewConnected = (id: string) => {
const { promise, resolve } = Promise.withResolvers<void>();
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
if (
payload.id === id &&
payload.type === 'block' &&
payload.method === 'add'
) {
subscription.unsubscribe();
resolve();
}
});
return promise;
};
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
await waitGfxViewConnected(id);
await waitViewConnected(id);
const gfxBlockView = gfx.view.get(id);
expect(gfxBlockView).not.toBeNull();
@@ -117,6 +180,824 @@ describe('gfx element view basic', () => {
expect(rootView).toBeNull();
});
test('detects low-zoom DOM survival mode only during active gestures for gesture-safe viewport configs', () => {
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.6,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: false,
gestureActive: true,
})
).toBe(false);
});
test('keeps selected block active while degrading unselected low-zoom viewport blocks', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(outOfViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_lastVisibleModels: Set<unknown>;
}
)._lastVisibleModels = new Set([
selectedModel,
inViewportModel,
outOfViewportModel,
]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('parks non-active low-zoom gesture blocks outside viewport DOM while gesture is running', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const nearbyId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const farVisibleId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(nearbyId),
waitViewConnected(farVisibleId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, nearbyId, '[20,0,10,10]');
setBlockXYWH(gfx, farVisibleId, '[120,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const nearbyModel = getTestGfxBlockModel(gfx, nearbyId);
const farVisibleModel = getTestGfxBlockModel(gfx, farVisibleId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const nearbyView = getTestGfxBlockView(gfx, nearbyId);
const farVisibleView = getTestGfxBlockView(gfx, farVisibleId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(nearbyModel).not.toBeNull();
expect(farVisibleModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(nearbyView).not.toBeNull();
expect(farVisibleView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.panning$.next(true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, nearbyModel, farVisibleModel]);
document.body.append(viewportElement);
viewportElement.append(
selectedView,
nearbyView,
farVisibleView,
outOfViewportView
);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(getViewportChildBlockIds(viewportElement)).toEqual([
selectedId,
nearbyId,
]);
expect(farVisibleView.isConnected).toBe(false);
expect(outOfViewportView.isConnected).toBe(false);
});
test('restores parked low-zoom blocks after gesture recovery completes', async () => {
vi.useFakeTimers();
try {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const firstId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const secondId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const thirdId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(firstId),
waitViewConnected(secondId),
waitViewConnected(thirdId),
]);
setBlockXYWH(gfx, firstId, '[0,0,10,10]');
setBlockXYWH(gfx, secondId, '[20,0,10,10]');
setBlockXYWH(gfx, thirdId, '[40,0,10,10]');
const firstModel = getTestGfxBlockModel(gfx, firstId);
const secondModel = getTestGfxBlockModel(gfx, secondId);
const thirdModel = getTestGfxBlockModel(gfx, thirdId);
const firstView = getTestGfxBlockView(gfx, firstId);
const secondView = getTestGfxBlockView(gfx, secondId);
const thirdView = getTestGfxBlockView(gfx, thirdId);
expect(firstModel).not.toBeNull();
expect(secondModel).not.toBeNull();
expect(thirdModel).not.toBeNull();
expect(firstView).not.toBeNull();
expect(secondView).not.toBeNull();
expect(thirdView).not.toBeNull();
gfx.selection.clear();
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.panning$.next(true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([firstModel, secondModel, thirdModel]);
document.body.append(viewportElement);
viewportElement.append(firstView, secondView, thirdView);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(viewportElement.children).toHaveLength(1);
gfx.viewport.panning$.next(false);
await vi.advanceTimersByTimeAsync(1200);
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
new Set([firstId, secondId, thirdId])
);
expect(firstView.transformState$.value).toBe('active');
expect(secondView.transformState$.value).toBe('active');
expect(thirdView.transformState$.value).toBe('active');
gfx.viewport.panning$.next(true);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(viewportElement.children).toHaveLength(1);
gfx.viewport.panning$.next(false);
await vi.advanceTimersByTimeAsync(1200);
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
new Set([firstId, secondId, thirdId])
);
expect(firstView.transformState$.value).toBe('active');
expect(secondView.transformState$.value).toBe('active');
expect(thirdView.transformState$.value).toBe('active');
} finally {
vi.useRealTimers();
}
});
test('programmatic low-zoom viewport changes do not arm gesture signals', async () => {
const { Viewport } = await import('../../gfx/index.js');
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
viewport.setViewport(0.4, [20, 0]);
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: viewport.zoom,
skipRefreshDuringGesture: viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive: viewport.panning$.value || viewport.zooming$.value,
})
).toBe(false);
});
test('programmatic low-zoom viewport changes still emit viewport updates', async () => {
const { Viewport } = await import('../../gfx/index.js');
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const updates: Array<{ zoom: number; center: [number, number] }> = [];
const subscription = viewport.viewportUpdated.subscribe(
({ zoom, center }) => {
updates.push({ zoom, center: [center[0], center[1]] });
}
);
viewport.setViewport(0.4, [20, 10]);
subscription.unsubscribe();
expect(updates).toEqual([
{
zoom: 0.4,
center: [20, 10],
},
]);
});
test('idles out-of-viewport blocks on the first visibility refresh', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('active');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('demotes visible unselected blocks immediately when zoom crosses into survival mode', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
(
viewportElement as unknown as {
_hideOutsideAndNoSelectedBlock: () => void;
}
)._hideOutsideAndNoSelectedBlock();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('active');
expect(outOfViewportView.transformState$.value).toBe('idle');
document.body.append(viewportElement);
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
await Promise.resolve();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('chunked low-zoom refresh idles out-of-viewport blocks on the first pass', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
await new Promise<void>(resolve => {
(
viewportElement as unknown as {
_chunkedHideOutsideAndNoSelectedBlock: (
onComplete?: () => void
) => () => void;
}
)._chunkedHideOutsideAndNoSelectedBlock(resolve);
});
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('newly mounted blocks inherit the current low-zoom visibility state', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await waitViewConnected(selectedId);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
expect(selectedModel).not.toBeNull();
expect(selectedView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportModels = new Set([selectedModel]);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () => viewportModels;
document.body.append(viewportElement);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
expect(inViewportModel).not.toBeNull();
expect(outOfViewportModel).not.toBeNull();
viewportModels.add(inViewportModel);
await Promise.all([
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('demotes stale active blocks immediately when low-zoom resize starts', async () => {
const { editorContainer, gfx, surfaceId } = await commonSetup();
const waitViewConnected = waitGfxViewConnected(gfx);
const selectedId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const inViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
const outOfViewportId = gfx.std.store.addBlock(
'test:gfx-block',
undefined,
surfaceId
);
await Promise.all([
waitViewConnected(selectedId),
waitViewConnected(inViewportId),
waitViewConnected(outOfViewportId),
]);
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
const selectedView = getTestGfxBlockView(gfx, selectedId);
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
expect(selectedModel).not.toBeNull();
expect(inViewportModel).not.toBeNull();
expect(selectedView).not.toBeNull();
expect(inViewportView).not.toBeNull();
expect(outOfViewportView).not.toBeNull();
gfx.selection.set({ elements: [selectedId], editing: false });
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
const viewportElement = new GfxViewportElement();
viewportElement.host = editorContainer.std.host;
viewportElement.viewport = gfx.viewport;
viewportElement.getModelsInViewport = () =>
new Set([selectedModel, inViewportModel]);
document.body.append(viewportElement);
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
selectedView.transformState$.value = 'active';
inViewportView.transformState$.value = 'active';
outOfViewportView.transformState$.value = 'active';
gfx.viewport.onResize();
expect(selectedView.transformState$.value).toBe('active');
expect(inViewportView.transformState$.value).toBe('survival');
expect(outOfViewportView.transformState$.value).toBe('idle');
});
test('resize completion clears low-zoom gesture recovery before sizeUpdated subscribers run', async () => {
const { gfx } = await commonSetup();
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
const shell = document.createElement('div');
Object.defineProperty(shell, 'offsetWidth', {
configurable: true,
get: () => 844,
});
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._shell = shell;
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
(
gfx.viewport as unknown as {
_shell: HTMLElement;
_cachedBoundingClientRect: DOMRect;
_cachedOffsetWidth: number;
}
)._cachedOffsetWidth = 844;
let panningAtSizeUpdated: boolean | null = null;
let zoomingAtSizeUpdated: boolean | null = null;
let blockSurvivalAtSizeUpdated: boolean | null = null;
let canvasRecoveryDelayAtSizeUpdated: number | null = null;
const subscription = gfx.viewport.sizeUpdated.subscribe(() => {
const gestureActive =
gfx.viewport.panning$.value || gfx.viewport.zooming$.value;
panningAtSizeUpdated = gfx.viewport.panning$.value;
zoomingAtSizeUpdated = gfx.viewport.zooming$.value;
blockSurvivalAtSizeUpdated = shouldUseLowZoomBlockSurvivalMode({
zoom: gfx.viewport.zoom,
skipRefreshDuringGesture: gfx.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive,
});
canvasRecoveryDelayAtSizeUpdated = getPostGestureRecoveryDelay({
isPanning: gfx.viewport.panning$.value,
isZooming: gfx.viewport.zooming$.value,
fallbackDelayMs: 800,
});
});
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
gfx.viewport.onResize();
await new Promise(resolve => setTimeout(resolve, 300));
subscription.unsubscribe();
expect(panningAtSizeUpdated).toBe(false);
expect(zoomingAtSizeUpdated).toBe(false);
expect(blockSurvivalAtSizeUpdated).toBe(false);
expect(canvasRecoveryDelayAtSizeUpdated).toBe(0);
});
test('local element view should be created', async () => {
const { gfx, surfaceModel } = await commonSetup();
const localElement = new TestLocalElement(surfaceModel);
@@ -1,3 +1,4 @@
import type { Bound } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { batch } from '@preact/signals-core';
import { css, html } from 'lit';
@@ -11,7 +12,11 @@ import {
import { PropTypes, requiredProperties } from '../view/decorators/required';
import { GfxControllerIdentifier } from './identifiers';
import { GfxBlockElementModel } from './model/gfx-block-model';
import { Viewport } from './viewport';
import {
getPostGestureRecoveryDelay,
Viewport,
viewportRuntimeConfig,
} from './viewport';
/**
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
@@ -37,6 +42,123 @@ export function requestThrottledConnectedFrame<
}) as T;
}
export function getGestureTransformMinInterval({
isPureTranslate,
zoom,
}: {
isPureTranslate: boolean;
zoom: number;
}) {
if (!isPureTranslate) {
return 32;
}
return zoom <= 0.5 ? 32 : 0;
}
export function shouldSkipGestureTransformWrite({
isPureTranslate,
zoom,
elapsedMs,
}: {
isPureTranslate: boolean;
zoom: number;
elapsedMs: number;
}) {
const minInterval = getGestureTransformMinInterval({
isPureTranslate,
zoom,
});
return minInterval > 0 && elapsedMs < minInterval;
}
const LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD = 0.5;
export function shouldUseLowZoomBlockSurvivalMode({
zoom,
skipRefreshDuringGesture,
gestureActive,
}: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) {
return (
skipRefreshDuringGesture &&
gestureActive &&
zoom <= LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD
);
}
export function getLowZoomGestureActiveModels<
T extends { elementBound: Bound; id: string },
>({
selectedModels,
viewportModels,
viewportBounds,
nearbyActiveBlockLimit,
nearbyDistanceRatio,
}: {
selectedModels: Set<T>;
viewportModels: Set<T>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}): Set<T> {
const activeModels = new Set<T>(selectedModels);
if (nearbyActiveBlockLimit <= 0) {
return activeModels;
}
const viewportCenter = viewportBounds.center;
const maxNearbyDistance =
Math.min(viewportBounds.w, viewportBounds.h) * nearbyDistanceRatio;
if (selectedModels.size === 0) {
const fallback = [...viewportModels]
.sort((left, right) => {
const [leftX, leftY] = left.elementBound.center;
const [rightX, rightY] = right.elementBound.center;
const leftDistance = Math.hypot(
leftX - viewportCenter[0],
leftY - viewportCenter[1]
);
const rightDistance = Math.hypot(
rightX - viewportCenter[0],
rightY - viewportCenter[1]
);
return leftDistance - rightDistance;
})
.slice(0, nearbyActiveBlockLimit);
fallback.forEach(model => activeModels.add(model));
return activeModels;
}
const selectedCenters = [...selectedModels].map(
model => model.elementBound.center
);
const nearbyCandidates = [...viewportModels]
.filter(model => !selectedModels.has(model))
.map(model => {
const [x, y] = model.elementBound.center;
const distance = Math.min(
...selectedCenters.map(([selectedX, selectedY]) =>
Math.hypot(x - selectedX, y - selectedY)
)
);
return { distance, model };
})
.filter(candidate => candidate.distance <= maxNearbyDistance)
.sort((left, right) => left.distance - right.distance)
.slice(0, nearbyActiveBlockLimit);
nearbyCandidates.forEach(candidate => activeModels.add(candidate.model));
return activeModels;
}
@requiredProperties({
viewport: PropTypes.instanceOf(Viewport),
})
@@ -45,6 +167,20 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
private get _pixelThreshold() {
return (
this.viewport?.VIEWPORT_REFRESH_PIXEL_THRESHOLD ??
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD
);
}
private get _maxInterval() {
return (
this.viewport?.VIEWPORT_REFRESH_MAX_INTERVAL ??
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL
);
}
static override styles = css`
gfx-viewport {
position: absolute;
@@ -63,38 +199,163 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
contain: size layout style;
}
/*
* Mobile (SKIP_REFRESH_DURING_GESTURE) drives gestures with a single
* container-level transform on <gfx-viewport>; the idle blocks never
* change their own transform during the gesture. In that mode
* 'will-change: transform' is actively harmful: WKWebView promotes every
* hidden idle block (100+) to its own compositing layer and re-transforms
* all of them each frame, producing a ~100ms main-thread/compositor stall
* that terminates the web content process. Releasing the hint lets them
* ride along as raster content of the single container layer.
* Desktop (no attribute) keeps will-change because it transforms blocks
* individually per frame, where the hint is a real win.
*/
gfx-viewport[data-skip-gesture-refresh] .block-idle {
will-change: auto;
}
/* CSS for active blocks participating in viewport transformations */
.block-active {
visibility: visible;
pointer-events: auto;
}
/* Survival blocks stay visually mounted but stop participating in input. */
.block-survival {
visibility: visible;
pointer-events: none;
}
`;
private readonly _parkedBlockViews = new Map<
string,
{ placeholder: Comment; view: HTMLElement }
>();
private readonly _parkedBlockFragment = document.createDocumentFragment();
private _shouldParkIdleBlocks() {
return (
shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
}) && this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0
);
}
private _restoreParkedBlockViews() {
this._parkedBlockViews.forEach(({ placeholder, view }) => {
if (placeholder.parentNode === this) {
placeholder.replaceWith(view);
} else if (!view.isConnected) {
this.append(view);
}
placeholder.remove();
});
this._parkedBlockViews.clear();
}
private _syncMountedBlockViews(
shouldRemainMounted: Set<GfxBlockElementModel>
) {
if (!this.host) return;
if (!this._shouldParkIdleBlocks()) {
this._restoreParkedBlockViews();
return;
}
const gfx = this.host.std.get(GfxControllerIdentifier);
gfx.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
const parked = this._parkedBlockViews.get(view.model.id);
if (shouldRemainMounted.has(view.model)) {
if (parked) {
if (parked.placeholder.parentNode === this) {
parked.placeholder.replaceWith(view);
} else if (!view.isConnected) {
this.append(view);
}
parked.placeholder.remove();
this._parkedBlockViews.delete(view.model.id);
} else if (!view.isConnected || view.parentElement !== this) {
this.append(view);
}
return;
}
if (parked || view.parentElement !== this) {
return;
}
const placeholder = document.createComment(`parked:${view.model.id}`);
this.replaceChild(placeholder, view);
this._parkedBlockFragment.append(view);
this._parkedBlockViews.set(view.model.id, {
placeholder,
view,
});
});
}
private readonly _hideOutsideAndNoSelectedBlock = () => {
if (!this.host) return;
const gfx = this.host.std.get(GfxControllerIdentifier);
const currentViewportModels = this.getModelsInViewport();
const currentSelectedModels = this._getSelectedModels();
const shouldBeVisible = new Set([
...currentViewportModels,
...currentSelectedModels,
]);
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const limitedActiveModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: currentViewportModels,
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
const shouldBeVisible =
limitedActiveModels ??
new Set([...currentViewportModels, ...currentSelectedModels]);
const previousVisible = this._lastVisibleModels
? new Set(this._lastVisibleModels)
: new Set<GfxBlockElementModel>();
const candidatesToHide = new Set(previousVisible);
if (!this._lastVisibleModels) {
this.host.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
candidatesToHide.add(view.model);
});
}
batch(() => {
// Step 1: Activate all the blocks that should be visible
shouldBeVisible.forEach(model => {
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) return;
view.transformState$.value = 'active';
view.transformState$.value = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
});
// Step 2: Hide all the blocks that should not be visible
previousVisible.forEach(model => {
candidatesToHide.forEach(model => {
if (shouldBeVisible.has(model)) return;
const view = gfx.view.get(model);
@@ -103,11 +364,161 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
});
});
this._syncMountedBlockViews(shouldBeVisible);
this._lastVisibleModels = shouldBeVisible;
};
/**
* Chunked version of _hideOutsideAndNoSelectedBlock that processes blocks
* in batches across multiple frames to prevent memory spikes on mobile.
* Returns a cancel function.
*/
private _chunkedHideOutsideAndNoSelectedBlock(
onComplete?: () => void
): () => void {
if (!this.host) return () => {};
const gfx = this.host.std.get(GfxControllerIdentifier);
const currentViewportModels = this.getModelsInViewport();
const currentSelectedModels = this._getSelectedModels();
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const limitedActiveModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: currentViewportModels,
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
const shouldBeVisible =
limitedActiveModels ??
new Set([...currentViewportModels, ...currentSelectedModels]);
const previousVisible = this._lastVisibleModels
? new Set(this._lastVisibleModels)
: new Set<GfxBlockElementModel>();
const candidatesToHide = new Set(previousVisible);
if (!this._lastVisibleModels) {
this.host.std.view.views.forEach(view => {
if (!isGfxBlockComponent(view)) return;
candidatesToHide.add(view.model);
});
}
// Compute which blocks need activation and which need hiding
const toActivate: GfxBlockElementModel[] = [];
shouldBeVisible.forEach(model => {
if (!previousVisible.has(model)) {
toActivate.push(model);
} else {
// Already visible, just ensure state is correct
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) {
return;
}
const targetState = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
if (view.transformState$.value !== targetState) {
toActivate.push(model);
}
}
});
const toHide: GfxBlockElementModel[] = [];
candidatesToHide.forEach(model => {
if (!shouldBeVisible.has(model)) {
toHide.push(model);
}
});
this._lastVisibleModels = shouldBeVisible;
// Hide blocks immediately (cheap: just sets visibility:hidden)
if (toHide.length > 0) {
batch(() => {
toHide.forEach(model => {
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) return;
view.transformState$.value = 'idle';
});
});
}
this._syncMountedBlockViews(shouldBeVisible);
// Activate blocks in chunks to prevent memory spikes
const CHUNK_SIZE = 8;
let chunkIndex = 0;
let cancelled = false;
let rafId: number | null = null;
const processNextChunk = () => {
if (cancelled) return;
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, toActivate.length);
if (start >= toActivate.length) {
onComplete?.();
return;
}
batch(() => {
for (let i = start; i < end; i++) {
const model = toActivate[i];
const view = gfx.view.get(model);
if (!isGfxBlockComponent(view)) continue;
view.transformState$.value = shouldLimitActiveModels
? 'active'
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
? 'survival'
: 'active';
}
});
chunkIndex++;
if (chunkIndex * CHUNK_SIZE < toActivate.length) {
rafId = requestAnimationFrame(processNextChunk);
} else {
onComplete?.();
}
};
// Start first chunk immediately (synchronous for responsiveness)
if (toActivate.length > 0) {
processNextChunk();
} else {
onComplete?.();
}
return () => {
cancelled = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
}
private _lastVisibleModels?: Set<GfxBlockElementModel>;
private _pendingChunkedHideCancel: (() => void) | null = null;
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
private _lastViewportRefreshTime = 0;
@@ -134,19 +545,49 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
}
}
private _cancelPendingChunkedHide() {
if (this._pendingChunkedHideCancel) {
this._pendingChunkedHideCancel();
this._pendingChunkedHideCancel = null;
}
}
private _scheduleChunkedHide(onComplete?: () => void) {
this._cancelPendingChunkedHide();
this._pendingChunkedHideCancel = this._chunkedHideOutsideAndNoSelectedBlock(
() => {
this._pendingChunkedHideCancel = null;
onComplete?.();
}
);
}
private _scheduleTrailingViewportRefresh() {
this._clearPendingViewportRefreshTimer();
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
this._pendingViewportRefreshTimer = null;
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
}, this._maxInterval);
}
private _refreshViewportByViewportUpdate(update: {
zoom: number;
center: [number, number];
}) {
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer all DOM mutations
// until panning/zooming ends to prevent main thread blocking
if (
this.viewport?.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
this._lastViewportUpdate = {
zoom: update.zoom,
center: [update.center[0], update.center[1]],
};
return;
}
const now = performance.now();
const previous = this._lastViewportUpdate;
this._lastViewportUpdate = {
@@ -166,13 +607,11 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
(update.center[1] - previous.center[1]) * update.zoom
);
const timeoutReached =
now - this._lastViewportRefreshTime >=
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
now - this._lastViewportRefreshTime >= this._maxInterval;
if (
zoomChanged ||
centerMovedInPixel >=
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
centerMovedInPixel >= this._pixelThreshold ||
timeoutReached
) {
this._clearPendingViewportRefreshTimer();
@@ -197,17 +636,303 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
this._refreshViewportByViewportUpdate(update)
)
);
this.disposables.add(
this.viewport.zoomUpdated.subscribe(({ previousZoom, zoom }) => {
const previousMode = shouldUseLowZoomBlockSurvivalMode({
zoom: previousZoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const nextMode = shouldUseLowZoomBlockSurvivalMode({
zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
if (previousMode !== nextMode) {
this._hideOutsideAndNoSelectedBlock();
}
})
);
this.disposables.add(
this.viewport.resizeStarted.subscribe(() => {
if (
!shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
})
) {
return;
}
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._lastVisibleModels = undefined;
this._scheduleChunkedHide();
})
);
this.disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
this._clearPendingViewportRefreshTimer();
this._lastViewportRefreshTime = performance.now();
this._refreshViewport();
// When SKIP_REFRESH_DURING_GESTURE is enabled, use chunked activation
// on resize (orientation change) to avoid a synchronous full refresh
// that causes white-screen flash on landscape with many elements.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
this._scheduleChunkedHide(() => {
this.viewport.viewportUpdated.next({
zoom: this.viewport.zoom,
center: [this.viewport.centerX, this.viewport.centerY],
});
});
} else {
this._refreshViewport();
}
})
);
if (!this.host) {
return;
}
this.disposables.add(
this.host.std.view.viewUpdated.subscribe(payload => {
if (payload.type !== 'block' || payload.method !== 'add') return;
if (!isGfxBlockComponent(payload.view)) return;
const currentSelectedModels = this._getSelectedModels();
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
zoom: this.viewport.zoom,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
});
const isSelected = currentSelectedModels.has(payload.view.model);
const isInViewport = this.getModelsInViewport().has(payload.view.model);
const shouldLimitActiveModels =
shouldUseSurvivalMode &&
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
const activeModels = shouldLimitActiveModels
? getLowZoomGestureActiveModels({
selectedModels: currentSelectedModels,
viewportModels: this.getModelsInViewport(),
viewportBounds: this.viewport.viewportBounds,
nearbyActiveBlockLimit:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
nearbyDistanceRatio:
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
})
: null;
payload.view.transformState$.value = isSelected
? 'active'
: isInViewport
? shouldLimitActiveModels
? activeModels?.has(payload.view.model)
? 'active'
: 'idle'
: shouldUseSurvivalMode
? 'survival'
: 'active'
: 'idle';
if (shouldLimitActiveModels && this._shouldParkIdleBlocks()) {
this._syncMountedBlockViews(activeModels ?? new Set());
}
})
);
// When SKIP_REFRESH_DURING_GESTURE is enabled, do one final refresh
// after panning/zooming ends to sync block visibility.
// Uses setTimeout (not requestIdleCallback) to guarantee a minimum delay
// before heavy work starts. requestIdleCallback fires immediately when
// idle, which doesn't protect against the "quick pause then resume" pattern.
// Uses chunked block activation to prevent memory spikes on mobile.
// Cancel if a new gesture starts before completion.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
// Marks this element so the stylesheet can drop 'will-change: transform'
// from idle blocks (see styles above): in this mode the gesture is driven
// by one container transform, so per-block layer promotion is pure
// overhead and stalls WKWebView's compositor.
this.dataset.skipGestureRefresh = '';
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
let cancelChunked: (() => void) | null = null;
// --- Container-level CSS transform during gestures ---
// Instead of updating N block transforms per frame (expensive),
// apply a single CSS transform on this element that represents the
// relative zoom/pan delta from the gesture start state.
// This keeps WKWebView's compositor in sync with only 1 DOM write/frame.
let gestureBaseZoom: number | null = null;
let gestureBaseTranslateX: number | null = null;
let gestureBaseTranslateY: number | null = null;
let gestureRAF: number | null = null;
let lastTransformTime = 0;
const applyContainerTransform = () => {
gestureRAF = null;
if (gestureBaseZoom === null) return;
const { zoom, translateX, translateY } = this.viewport;
const relativeScale = zoom / gestureBaseZoom;
const isPureTranslate = Math.abs(relativeScale - 1) < 1e-3;
const now = performance.now();
// Scale gestures were already throttled here. The new evidence shows the
// crash can still happen while all editor/scroll counters stay at zero,
// which points back to this gesture-time container transform path.
// On iOS at far-out zoom (the 0.4 repro band), even pure translate can
// still move a very large layer tree (17 canvases + active blocks). So
// we now also throttle pure-translate writes in that zoom band instead of
// assuming they are always cheap.
if (
shouldSkipGestureTransformWrite({
isPureTranslate,
zoom,
elapsedMs: now - lastTransformTime,
})
) {
gestureRAF = requestAnimationFrame(applyContainerTransform);
return;
}
lastTransformTime = now;
// Container transform: scale changes block sizes, translate compensates
// for the center shift. Formula: final_pos = container_translate + scale * base_pos
// We need: container_translate + scale * base_pos = current_pos
// => container_translate = current_translate - scale * base_translate
const dx = translateX - relativeScale * gestureBaseTranslateX!;
const dy = translateY - relativeScale * gestureBaseTranslateY!;
// Pure pan (relativeScale === 1) is the common gesture and the one that
// crashes WKWebView's compositor: a transform that carries scale() keeps
// the layer on the "non-trivial transform" path, so WebKit re-rasterizes
// the whole container — and with OVERSCAN_RATIO that canvas area is
// roughly 2x the visible area behind many canvas layers, which overruns
// the GPU compositor (rafGap spikes while drift stays low). Emitting a bare
// translate() instead routes panning through the cheap layer-move fast
// path with no re-rasterization. The math is identical when scale === 1
// (dx/dy already reduce to the pan delta), so this is exact, not a
// visual approximation. scale() is only emitted for actual zoom.
this.style.transform = isPureTranslate
? `translate(${dx}px, ${dy}px)`
: `translate(${dx}px, ${dy}px) scale(${relativeScale})`;
this.style.transformOrigin = '0 0';
};
const scheduleContainerTransform = () => {
if (gestureRAF === null) {
gestureRAF = requestAnimationFrame(applyContainerTransform);
}
};
const startGestureTransform = () => {
gestureBaseZoom = this.viewport.zoom;
gestureBaseTranslateX = this.viewport.translateX;
gestureBaseTranslateY = this.viewport.translateY;
// Let the first frame of a new gesture apply immediately.
lastTransformTime = 0;
};
const clearContainerTransform = () => {
if (gestureRAF !== null) {
cancelAnimationFrame(gestureRAF);
gestureRAF = null;
}
gestureBaseZoom = null;
gestureBaseTranslateX = null;
gestureBaseTranslateY = null;
this.style.transform = 'none';
};
// --- End-of-gesture recovery ---
const cancelPendingRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
if (cancelChunked !== null) {
cancelChunked();
cancelChunked = null;
}
};
const scheduleIdleRefresh = () => {
cancelPendingRefresh();
const delayMs = getPostGestureRecoveryDelay({
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
});
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
// If a gesture is still in-flight when the timer fires (e.g. inertial
// scroll or clamped setZoom at the zoom floor keeps re-arming the
// panning$/zooming$ debounce), do NOT drop the refresh — reschedule
// it. Dropping here is what left connectors/elements blank until the
// user tapped to force a synchronous refresh.
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
scheduleIdleRefresh();
return;
}
// Remove container transform before per-block update
clearContainerTransform();
this._lastViewportRefreshTime = performance.now();
// Use chunked activation to spread block rendering across frames
cancelChunked = this._chunkedHideOutsideAndNoSelectedBlock(() => {
cancelChunked = null;
// After all blocks are activated, emit viewportUpdated
// to update individual block transforms
this.viewport.viewportUpdated.next({
zoom: this.viewport.zoom,
center: [this.viewport.centerX, this.viewport.centerY],
});
});
}, delayMs);
};
// Listen to panning$ to drive the container transform during gestures
// and handle end-of-gesture recovery
this.disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) {
if (gestureBaseZoom === null) {
startGestureTransform();
}
scheduleContainerTransform();
cancelPendingRefresh();
} else {
scheduleIdleRefresh();
}
})
);
this.disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) {
if (gestureBaseZoom === null) {
startGestureTransform();
}
scheduleContainerTransform();
cancelPendingRefresh();
} else {
scheduleIdleRefresh();
}
})
);
this.disposables.add({
dispose: () => {
cancelPendingRefresh();
clearContainerTransform();
},
});
}
}
override disconnectedCallback(): void {
this._clearPendingViewportRefreshTimer();
this._cancelPendingChunkedHide();
this._restoreParkedBlockViews();
super.disconnectedCallback();
}
+298 -32
View File
@@ -23,6 +23,120 @@ export const ZOOM_INITIAL = 1.0;
export const FIT_TO_SCREEN_PADDING = 100;
/**
* Process-wide defaults applied to every {@link Viewport} at construction.
*
* Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
* zoom floor and defer DOM mutations during gestures to avoid WKWebView process
* termination) override these once at startup, before any editor mounts. This
* guarantees both the editor and the readonly preview viewports are born with
* the same limits — avoiding the race and wrong-instance problems of patching a
* single Viewport asynchronously after it has already mounted.
*
* Desktop leaves these untouched, so its behavior is unchanged.
*/
export const viewportRuntimeConfig = {
ZOOM_MIN,
ZOOM_MAX,
VIEWPORT_REFRESH_PIXEL_THRESHOLD: 18,
VIEWPORT_REFRESH_MAX_INTERVAL: 120,
SKIP_REFRESH_DURING_GESTURE: false,
/**
* Delay (ms) before the post-gesture refresh repaints canvases and reactivates
* blocks, used only when {@link SKIP_REFRESH_DURING_GESTURE} is true. The same
* value drives both the canvas and block refresh timers so they fire together
* (avoiding the "blocks appear, then connectors" staggered reveal). Desktop
* never enters that code path, so this is mobile-only.
*/
POST_GESTURE_REFRESH_DELAY: 800,
/**
* Caps the canvas backing-store device-pixel-ratio at low zoom.
*
* Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
* When the live zoom is below a threshold, the corresponding cap bounds the
* effective dpr used to size canvases. Far-out zoom makes content tiny on
* screen, so a full retina backing store is wasted memory — on iOS that waste
* is what pushes WKWebView past its compositing budget and crashes the web
* content process during pan/zoom.
*
* Empty (the desktop default) means no cap: canvases always use the raw
* `window.devicePixelRatio`, so desktop behavior is unchanged.
*/
CANVAS_DPR_CAP_BY_ZOOM: [] as Array<[number, number]>,
/**
* Fraction by which the *render/activation* viewport bound is enlarged on
* every side (see {@link Viewport.overscanViewportBounds}). Pre-painting a
* margin around the visible area means moderate pan/zoom gestures move into
* content that is already mounted and rasterized, so it does not blank out
* and wait for the post-gesture refresh.
*
* Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
* and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
* makes {@link Viewport.overscanViewportBounds} identical to
* {@link Viewport.viewportBounds}, leaving desktop behavior unchanged.
*
* This governs the *canvas* render bound only (see
* {@link Viewport.overscanViewportBounds}). It enlarges the canvas backing
* stores, so memory grows with the overscan area. Keep it modest and pair it
* with the mobile zoom floor + dpr cap so connectors/elements stay painted
* through a gesture without pushing WKWebView over budget.
*/
OVERSCAN_RATIO: 0,
/**
* Like {@link OVERSCAN_RATIO} but for the *DOM block mounting* bound (see
* {@link Viewport.overscanBlockBounds}). This one is expensive: every
* mounted block becomes its own composited layer subtree in the WebContent
* process, so enlarging it multiplies resident memory and is what pushes the
* process toward an iOS jetsam kill. Keep this small (or `0`) even when
* {@link OVERSCAN_RATIO} is generous. `0` (desktop default) leaves block
* mounting on the exact visible bound, unchanged from upstream.
*/
OVERSCAN_RATIO_BLOCK: 0,
/**
* During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
* as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
* behavior where every viewport block remains visually mounted as `survival`.
*/
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT: 0,
/**
* Distance threshold (as a fraction of the viewport's shorter side) used to
* decide whether an unselected viewport block counts as "nearby" to the
* current selection during low-zoom gesture survival mode.
*/
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO: 0.35,
};
export function getPostGestureRecoveryDelay({
isPanning,
isZooming,
fallbackDelayMs,
}: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) {
return isPanning || isZooming ? fallbackDelayMs : 0;
}
/**
* Resolves the effective device-pixel-ratio for canvas backing stores at the
* given zoom, honoring {@link viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM}.
*
* Returns the raw `window.devicePixelRatio` when no cap applies.
*/
export function getEffectiveDpr(
zoom: number,
rawDpr = window.devicePixelRatio
): number {
const caps = viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM;
for (const [zoomThreshold, dprCap] of caps) {
if (zoom < zoomThreshold) {
return Math.min(rawDpr, dprCap);
}
}
return rawDpr;
}
export interface ViewportRecord {
left: number;
top: number;
@@ -92,6 +206,13 @@ export class Viewport {
top: number;
}>();
resizeStarted = new Subject<{
width: number;
height: number;
left: number;
top: number;
}>();
viewportMoved = new Subject<IVec>();
viewportUpdated = new Subject<{
@@ -99,12 +220,71 @@ export class Viewport {
center: IVec;
}>();
zoomUpdated = new Subject<{
previousZoom: number;
zoom: number;
}>();
zooming$ = new BehaviorSubject<boolean>(false);
panning$ = new BehaviorSubject<boolean>(false);
ZOOM_MAX = ZOOM_MAX;
/**
* Per-instance override for the maximum zoom. When unset, the value is read
* dynamically from {@link viewportRuntimeConfig} so that runtime overrides
* (e.g. iOS mobile-safe limits configured at app startup) always apply,
* regardless of whether this instance was constructed before or after the
* override ran.
*/
private _zoomMaxOverride?: number;
ZOOM_MIN = ZOOM_MIN;
private _zoomMinOverride?: number;
get ZOOM_MAX() {
return this._zoomMaxOverride ?? viewportRuntimeConfig.ZOOM_MAX;
}
set ZOOM_MAX(value: number) {
this._zoomMaxOverride = value;
}
get ZOOM_MIN() {
return this._zoomMinOverride ?? viewportRuntimeConfig.ZOOM_MIN;
}
set ZOOM_MIN(value: number) {
this._zoomMinOverride = value;
}
/**
* Minimum pixel movement before triggering a viewport refresh during panning.
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
* Default: 18 (desktop-optimized).
*/
VIEWPORT_REFRESH_PIXEL_THRESHOLD =
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD;
/**
* Maximum interval (ms) between viewport refreshes during continuous interaction.
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
* Default: 120 (desktop-optimized).
*/
VIEWPORT_REFRESH_MAX_INTERVAL =
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL;
/**
* When true, viewport element visibility refreshes are skipped entirely during
* panning/zooming, deferring all DOM mutations until the gesture ends.
* Prevents JS main thread blocking that can cause WKWebView process termination.
* Default: false (desktop behavior unchanged).
*/
SKIP_REFRESH_DURING_GESTURE =
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE;
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT =
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT;
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO =
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO;
private readonly _resetZooming = debounce(() => {
this.zooming$.next(false);
@@ -144,7 +324,7 @@ export class Viewport {
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
const newCenterY = initialTopLeftY + height / (2 * this.zoom);
this.setCenter(newCenterX, newCenterY, false);
this.setCenter(newCenterX, newCenterY, false, false);
this._width = width;
this._height = height;
this._left = left;
@@ -245,6 +425,49 @@ export class Viewport {
});
}
/**
* Like {@link viewportBounds} but enlarged by
* {@link viewportRuntimeConfig.OVERSCAN_RATIO} on every side. Used only by
* the *canvas* render path so that gestures move into already-rasterized
* vector content instead of blank space. This also enlarges the canvas
* backing store, so keep the ratio conservative.
*
* Hit-testing, selection and other geometry must keep using the exact
* {@link viewportBounds}; do not substitute this for those.
*/
get overscanViewportBounds() {
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO);
}
/**
* Like {@link overscanViewportBounds} but governed by the separate, smaller
* {@link viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK}. Used only by the *DOM
* block mounting* path. Expensive: every mounted block adds a composited
* layer subtree, so this must stay small to keep the WebContent process
* under the iOS jetsam memory limit even when canvas overscan is generous.
*/
get overscanBlockBounds() {
return this._enlargeBounds(viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK);
}
private _enlargeBounds(ratio: number) {
const bounds = this.viewportBounds;
if (ratio <= 0) {
return bounds;
}
const marginX = bounds.w * ratio;
const marginY = bounds.h * ratio;
return new Bound(
bounds.x - marginX,
bounds.y - marginY,
bounds.w + marginX * 2,
bounds.h + marginY * 2
);
}
get viewportMaxXY() {
const { centerX, centerY, width, height, zoom } = this;
return {
@@ -297,8 +520,10 @@ export class Viewport {
dispose() {
this.clearViewportElement();
this.sizeUpdated.complete();
this.resizeStarted.complete();
this.viewportMoved.complete();
this.viewportUpdated.complete();
this.zoomUpdated.complete();
this._resizeSubject.complete();
this.zooming$.complete();
this.panning$.complete();
@@ -307,7 +532,7 @@ export class Viewport {
getFitToScreenData(
bounds?: Bound | null,
padding: [number, number, number, number] = [0, 0, 0, 0],
maxZoom = ZOOM_MAX,
maxZoom = this.ZOOM_MAX,
fitToScreenPadding = 100
) {
let { centerX, centerY, zoom } = this;
@@ -324,7 +549,11 @@ export class Viewport {
(width - fitToScreenPadding - (pr + pl)) / w,
(height - fitToScreenPadding - (pt + pb)) / h
);
zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX));
zoom = clamp(
zoom,
this.ZOOM_MIN,
clamp(maxZoom, this.ZOOM_MIN, this.ZOOM_MAX)
);
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
@@ -353,6 +582,12 @@ export class Viewport {
this._left = left;
this._top = top;
this.resizeStarted.next({
left,
top,
width,
height,
});
this._resizeSubject.next({
left,
top,
@@ -367,19 +602,39 @@ export class Viewport {
* @param centerY The new y coordinate of the center of the viewport.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setCenter(centerX: number, centerY: number, forceUpdate = true) {
setCenter(
centerX: number,
centerY: number,
forceUpdate = true,
signalPanning = true
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
}
this._center.x = centerX;
this._center.y = centerY;
this.panning$.next(true);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetPanning();
const gestureActive = this.panning$.value || this.zooming$.value;
if (signalPanning) {
this.panning$.next(true);
}
// When SKIP_REFRESH_DURING_GESTURE is active, suppress viewportUpdated
// emissions during gestures. Heavy subscribers (canvas, DOM visibility,
// per-block transforms) would otherwise fire on every gesture event.
// Instead, the viewport-element applies a lightweight container-level
// CSS transform to keep visuals in sync with zero per-block overhead.
if (!(this.SKIP_REFRESH_DURING_GESTURE && gestureActive)) {
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
}
if (signalPanning) {
this._resetPanning();
}
}
setRect(left: number, top: number, width: number, height: number) {
@@ -410,7 +665,8 @@ export class Viewport {
newZoom: number,
newCenter = Vec.toVec(this.center),
smooth = false,
forceUpdate = true
forceUpdate = true,
signalGesture = false
) {
// Force complete any pending resize operations if forceUpdate is true
if (forceUpdate && this._isResizing) {
@@ -421,19 +677,19 @@ export class Viewport {
if (smooth) {
const cofficient = preZoom / newZoom;
if (cofficient === 1) {
this.smoothTranslate(newCenter[0], newCenter[1]);
this.smoothTranslate(newCenter[0], newCenter[1], 10, signalGesture);
} else {
const center = [this.centerX, this.centerY] as IVec;
const focusPoint = Vec.mul(
Vec.sub(newCenter, Vec.mul(center, cofficient)),
1 / (1 - cofficient)
);
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
this.smoothZoom(newZoom, Vec.toPoint(focusPoint), 10, signalGesture);
}
} else {
this._center.x = newCenter[0];
this._center.y = newCenter[1];
this.setZoom(newZoom, undefined, false, forceUpdate);
this.setZoom(newZoom, undefined, false, forceUpdate, signalGesture);
}
}
@@ -450,7 +706,8 @@ export class Viewport {
bound: Bound,
padding: [number, number, number, number] = [0, 0, 0, 0],
smooth = false,
forceUpdate = true
forceUpdate = true,
signalGesture = false
) {
let [pt, pr, pb, pl] = padding;
@@ -485,7 +742,7 @@ export class Viewport {
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
] as IVec;
this.setViewport(zoom, center, smooth, forceUpdate);
this.setViewport(zoom, center, smooth, forceUpdate, signalGesture);
}
/** This is the outer container of the viewport, which is the host of the viewport element */
@@ -509,14 +766,15 @@ export class Viewport {
* Set the viewport to the new zoom.
* @param zoom The new zoom value.
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
* @param wheel Whether the zoom is caused by wheel event.
* @param _wheel Legacy parameter kept for call-site compatibility.
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
*/
setZoom(
zoom: number,
focusPoint?: IPoint,
wheel = false,
forceUpdate = true
_wheel = false,
forceUpdate = true,
signalGesture = false
) {
if (forceUpdate && this._isResizing) {
this._forceCompleteResize();
@@ -532,18 +790,21 @@ export class Viewport {
Vec.toVec(focusPoint),
Vec.mul(offset, prevZoom / newZoom)
);
if (wheel) {
// Always signal zooming for any real gesture zoom change (pinch or wheel).
// Programmatic viewport changes should use the normal refresh path without
// entering low-zoom gesture survival mode.
if (signalGesture) {
this.zooming$.next(true);
}
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
this.viewportUpdated.next({
zoom: this.zoom,
center: Vec.toVec(this.center) as IVec,
});
this._resetZooming();
this.setCenter(newCenter[0], newCenter[1], forceUpdate, signalGesture);
this.zoomUpdated.next({ previousZoom: prevZoom, zoom: newZoom });
// setCenter already emits viewportUpdated, no need to emit again here.
if (signalGesture) {
this._resetZooming();
}
}
smoothTranslate(x: number, y: number, numSteps = 10) {
smoothTranslate(x: number, y: number, numSteps = 10, signalGesture = false) {
const { center } = this;
const delta = { x: x - center.x, y: y - center.y };
const innerSmoothTranslate = () => {
@@ -558,7 +819,7 @@ export class Viewport {
const signY = delta.y > 0 ? 1 : -1;
nextCenter.x = cutoff(nextCenter.x, x, signX);
nextCenter.y = cutoff(nextCenter.y, y, signY);
this.setCenter(nextCenter.x, nextCenter.y, true);
this.setCenter(nextCenter.x, nextCenter.y, true, signalGesture);
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
});
@@ -566,7 +827,12 @@ export class Viewport {
innerSmoothTranslate();
}
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
smoothZoom(
zoom: number,
focusPoint?: IPoint,
numSteps = 10,
signalGesture = false
) {
const delta = zoom - this.zoom;
if (this._rafId) cancelAnimationFrame(this._rafId);
@@ -576,7 +842,7 @@ export class Viewport {
const step = delta / numSteps;
const nextZoom = cutoff(this.zoom + step, zoom, sign);
this.setZoom(nextZoom, focusPoint, undefined, true);
this.setZoom(nextZoom, focusPoint, undefined, true, signalGesture);
if (nextZoom != zoom) innerSmoothZoom();
});
@@ -42,12 +42,17 @@ function updateBlockVisibility(view: GfxBlockComponent) {
if (view.transformState$.value === 'active') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'auto';
view.classList.remove('block-idle');
view.classList.remove('block-idle', 'block-survival');
view.classList.add('block-active');
} else if (view.transformState$.value === 'survival') {
view.style.visibility = 'visible';
view.style.pointerEvents = 'none';
view.classList.remove('block-active', 'block-idle');
view.classList.add('block-survival');
} else {
view.style.visibility = 'hidden';
view.style.pointerEvents = 'none';
view.classList.remove('block-active');
view.classList.remove('block-active', 'block-survival');
view.classList.add('block-idle');
}
}
@@ -55,8 +60,19 @@ function updateBlockVisibility(view: GfxBlockComponent) {
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
const viewport = instance.gfx.viewport;
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.subscribe(() => {
viewport.viewportUpdated.subscribe(() => {
// When SKIP_REFRESH_DURING_GESTURE is enabled and a gesture is active,
// skip per-block transform updates. The viewport-element applies a
// container-level CSS transform to keep visuals in sync instead.
if (
viewport.SKIP_REFRESH_DURING_GESTURE &&
(viewport.panning$.value || viewport.zooming$.value)
) {
return;
}
updateTransform(instance);
})
);
@@ -95,7 +111,7 @@ export abstract class GfxBlockComponent<
{
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
get gfx() {
return this.std.get(GfxControllerIdentifier);
@@ -207,7 +223,7 @@ export function toGfxBlockComponent<
return class extends CustomBlock {
[GfxElementSymbol] = true;
readonly transformState$ = signal<'idle' | 'active'>('active');
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
override selected$ = computed(() => {
const selection = this.std.selection.value.find(