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
+8
View File
@@ -6,6 +6,7 @@
!.yarn/releases
!.yarn/sdks
.yarn/versions
.corepack-bin
# compiled output
*dist
@@ -50,6 +51,7 @@ tsconfig.tsbuildinfo
.context
/*.md
.codex
.cursor
# System Files
.DS_Store
@@ -94,3 +96,9 @@ af.cmd
# playwright
storageState.json
/.understand-anything
# local test/browser artifacts
/.playwright-browsers/
**/.vitest-attachments/
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
@@ -0,0 +1,770 @@
import { Bound } from '@blocksuite/global/gfx';
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
import {
paintPlaceholder,
syncCanvasSize,
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
window,
'devicePixelRatio'
);
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value,
});
}
function createRect(width: number, height: number): DOMRect {
return {
width,
height,
left: 0,
top: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createFakeBlockModel(
id: string,
x: number,
y: number,
w = 10,
h = 10
) {
return {
id,
elementBound: new Bound(x, y, w, h),
};
}
type PaintPlaceholderForTest = (
canvas: HTMLCanvasElement,
layout: ViewportLayoutTree,
viewport: {
zoom: number;
toViewCoord: (x: number, y: number) => [number, number];
}
) => void;
afterEach(() => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
if (originalDevicePixelRatio) {
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
}
vi.restoreAllMocks();
});
describe('edgeless canvas budget', () => {
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
expect(
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
).toBe(true);
const shouldSyncCanvasBudgetOnViewportUpdate = (
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate;
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
});
test('enables low-zoom survival mode only for active iOS gestures', () => {
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
const shouldUseLowZoomSurvivalMode = (
canvasRendererModule as {
shouldUseLowZoomSurvivalMode: (
isIOS: boolean,
zoom: number,
gestureActive: boolean
) => boolean;
}
).shouldUseLowZoomSurvivalMode;
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
});
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
const shouldRenderCanvasPlaceholders = (
canvasRendererModule as {
shouldRenderCanvasPlaceholders: (params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) => boolean;
}
).shouldRenderCanvasPlaceholders;
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: true,
isZooming: false,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(false);
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: false,
isZooming: true,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(true);
});
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
const getStackingCanvasBypassState = (
canvasRendererModule as {
getStackingCanvasBypassState: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).getStackingCanvasBypassState;
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: true,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 430,
viewportHeight: 932,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.6,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: false,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
});
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvases = [document.createElement('canvas')];
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
});
expect(shouldBypass).toBe(true);
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: !shouldBypass,
})
).toEqual({
added: [],
removed: canvases,
});
});
test('uses overscan for main-canvas fallback culling and render origin', () => {
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
const getMainCanvasFallbackBounds = (
canvasRendererModule as {
getMainCanvasFallbackBounds: (params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) => {
cullBound: Bound;
renderBound: Bound;
};
}
).getMainCanvasFallbackBounds;
const viewportBounds = new Bound(100, 200, 300, 150);
const overscanViewportBounds = new Bound(40, 170, 420, 210);
expect(
getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
})
).toEqual({
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
});
});
test('lays out overscan canvases relative to the exact viewport', () => {
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
const getCanvasViewportLayout = (
canvasRendererModule as {
getCanvasViewportLayout: (params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) => {
actualHeight: number;
actualWidth: number;
height: number;
transform: string;
width: number;
};
}
).getCanvasViewportLayout;
expect(
getCanvasViewportLayout({
bound: new Bound(40, 170, 420, 210),
viewportBounds: new Bound(100, 200, 300, 150),
zoom: 1,
viewScale: 1,
dpr: 2,
})
).toEqual({
actualHeight: 420,
actualWidth: 840,
height: 210,
transform: 'translate(-60px, -30px) scale(1)',
width: 420,
});
});
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvasA = document.createElement('canvas');
const canvasB = document.createElement('canvas');
const canvases = [canvasA, canvasB];
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: false,
})
).toEqual({
added: [],
removed: canvases,
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: false,
shouldAttach: true,
})
).toEqual({
added: canvases,
removed: [],
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: true,
})
).toEqual({
added: [],
removed: [],
});
});
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
let lastCanvasBudgetZoom = viewport.zoom;
let budgetSyncCount = 0;
viewport.viewportUpdated.subscribe(viewportUpdated);
expect('zoomUpdated' in viewport).toBe(true);
const zoomUpdated = (
viewport as unknown as {
zoomUpdated: {
subscribe: (
callback: (update: { previousZoom: number; zoom: number }) => void
) => void;
};
}
).zoomUpdated;
zoomUpdated.subscribe(update => {
zoomUpdates.push(update);
if (
(
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate(
lastCanvasBudgetZoom,
update.zoom,
2
)
) {
budgetSyncCount += 1;
}
lastCanvasBudgetZoom = update.zoom;
});
viewport.panning$.next(true);
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
expect(viewportUpdated).not.toHaveBeenCalled();
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
expect(budgetSyncCount).toBe(1);
viewport.dispose();
});
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdated = vi.fn();
viewport.viewportUpdated.subscribe(viewportUpdated);
viewport.zoomUpdated.subscribe(zoomUpdated);
viewport.setZoom(0.4, { x: 0, y: 0 });
expect(viewportUpdated).toHaveBeenCalledTimes(1);
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
viewport.dispose();
});
test('enables low-zoom block survival only while the gesture is still active', () => {
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
true
);
const shouldUseLowZoomBlockSurvivalMode = (
viewportElementModule as {
shouldUseLowZoomBlockSurvivalMode: (params: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) => boolean;
}
).shouldUseLowZoomBlockSurvivalMode;
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
});
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const selected = createFakeBlockModel('selected', 10, 10);
const nearby = createFakeBlockModel('nearby', 28, 12);
const far = createFakeBlockModel('far', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set([selected]),
viewportModels: new Set([selected, nearby, far]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id).sort()).toEqual([
'nearby',
'selected',
]);
});
test('falls back to the nearest viewport block when nothing is selected', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const nearest = createFakeBlockModel('nearest', 46, 46);
const farther = createFakeBlockModel('farther', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set(),
viewportModels: new Set([nearest, farther]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
});
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(0);
});
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: true,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(220);
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: true,
fallbackDelayMs: 220,
})
).toBe(220);
});
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const host = document.createElement('div');
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
createRect(200, 100)
);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.4);
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(100);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.95);
expect(canvas.width).toBe(400);
expect(canvas.height).toBe(200);
});
test('paints turbo placeholders with effective dpr at low zoom', () => {
const previousTheme = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = 'light';
try {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const fillRect = vi.fn();
const strokeRect = vi.fn();
let fillStyle = '';
let strokeStyle = '';
vi.spyOn(canvas, 'getContext').mockReturnValue({
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string) {
fillStyle = value;
},
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value: string) {
strokeStyle = value;
},
fillRect,
strokeRect,
} as unknown as CanvasRenderingContext2D);
const layout: ViewportLayoutTree = {
roots: [
{
blockId: 'root',
type: 'affine:page',
layout: {
blockId: 'root',
type: 'affine:page',
rect: { x: 0, y: 0, w: 50, h: 20 },
},
children: [],
},
],
overallRect: { x: 0, y: 0, w: 50, h: 20 },
};
const paintPlaceholderForTest =
paintPlaceholder as unknown as PaintPlaceholderForTest;
paintPlaceholderForTest(canvas, layout, {
zoom: 0.4,
toViewCoord: () => [0, 0],
});
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
paintPlaceholderForTest(canvas, layout, {
zoom: 0.95,
toViewCoord: () => [0, 0],
});
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
} finally {
document.documentElement.dataset.theme = previousTheme;
}
});
});
@@ -0,0 +1,34 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { describe, expect, it } from 'vitest';
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '../../../../shared/src/theme/placeholder-style.js';
describe('affine placeholder style', () => {
it('returns subtle light placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.04)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.02)'
);
});
it('returns subtle dark placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.08)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.04)'
);
});
it('infers color scheme from theme mode', () => {
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
describe('viewport turbo renderer policy', () => {
test.each([
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
])(
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
({ isIOS, zoom, hasBitmap, expected }) => {
expect(
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
).toBe(true);
const shouldPreferBitmapCacheDuringLowZoomGesture = (
turboRendererModule as {
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) => boolean;
}
).shouldPreferBitmapCacheDuringLowZoomGesture;
expect(
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS,
zoom,
hasBitmap,
})
).toBe(expected);
}
);
test.each([
{ isIOS: true, zoom: 0.4, expected: false },
{ isIOS: true, zoom: 0.8, expected: true },
{ isIOS: false, zoom: 0.4, expected: true },
])(
'idles turbo blocks outside iOS low-zoom survival mode %#',
({ isIOS, zoom, expected }) => {
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
true
);
const shouldIdleTurboBlocksDuringZooming = (
turboRendererModule as {
shouldIdleTurboBlocksDuringZooming: (params: {
isIOS: boolean;
zoom: number;
}) => boolean;
}
).shouldIdleTurboBlocksDuringZooming;
expect(
shouldIdleTurboBlocksDuringZooming({
isIOS,
zoom,
})
).toBe(expected);
}
);
});
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
currentCenter.y
);
viewport.setZoom(zoom, new Point(baseX, baseY));
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
return false;
})
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
);
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
viewport.setZoom(zoom, new Point(baseX, baseY), true);
viewport.setZoom(zoom, new Point(baseX, baseY), true, true, true);
e.stopPropagation();
}
// pan
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
.viewport=${this.gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this.gfx.grid.search(
this.gfx.viewport.viewportBounds,
this.gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
.viewport=${this._gfx.viewport}
.getModelsInViewport=${() => {
const blocks = this._gfx.grid.search(
this._gfx.viewport.viewportBounds,
this._gfx.viewport.overscanBlockBounds,
{
useSet: true,
filter: ['block'],
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import {
Bound,
getBoundWithRotation,
@@ -18,7 +19,12 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
getEffectiveDpr,
getPostGestureRecoveryDelay,
GfxControllerIdentifier,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import last from 'lodash-es/last';
import { Subject } from 'rxjs';
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
import { RoughCanvas } from '../utils/rough/canvas.js';
import type { ElementRenderer } from './elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -116,6 +123,181 @@ type RefreshTarget =
};
const STACKING_CANVAS_PADDING = 32;
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldSyncCanvasBudgetOnViewportUpdate(
previousZoom: number,
nextZoom: number,
rawDpr = window.devicePixelRatio
) {
if (rawDpr <= 1) {
return false;
}
return (
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
);
}
export function shouldUseLowZoomSurvivalMode(
isIOS: boolean,
zoom: number,
gestureActive: boolean
) {
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
}
export function getStackingCanvasBypassState(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
const {
isIOS,
zoom,
gestureActive,
recoveryActive,
viewportWidth,
viewportHeight,
} = params;
return (
isIOS &&
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
(gestureActive || recoveryActive) &&
viewportWidth > viewportHeight
);
}
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) {
return getStackingCanvasBypassState(params);
}
export function getStackingCanvasAttachmentDiff(params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) {
const { canvases, wasAttached, shouldAttach } = params;
if (wasAttached === shouldAttach) {
return {
added: [],
removed: [],
};
}
return shouldAttach
? {
added: canvases,
removed: [],
}
: {
added: [],
removed: canvases,
};
}
export function getMainCanvasFallbackBounds(params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) {
const { overscanViewportBounds } = params;
return {
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
};
}
export function getCanvasViewportLayout(params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) {
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
return {
actualHeight: Math.max(0, Math.ceil(height * dpr)),
actualWidth: Math.max(0, Math.ceil(width * dpr)),
height,
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
width,
};
}
function applyCanvasViewportLayout(
canvas: HTMLCanvasElement,
layout: ReturnType<typeof getCanvasViewportLayout>
) {
const width = `${layout.width}px`;
const height = `${layout.height}px`;
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== width) {
canvas.style.width = width;
}
if (canvas.style.height !== height) {
canvas.style.height = height;
}
if (canvas.style.transform !== layout.transform) {
canvas.style.transform = layout.transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== layout.actualWidth) {
canvas.width = layout.actualWidth;
}
if (canvas.height !== layout.actualHeight) {
canvas.height = layout.actualHeight;
}
}
export function shouldRenderCanvasPlaceholders(params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) {
const {
isIOS,
zoom,
isPanning,
isZooming,
skipRefreshDuringGesture,
turboEnabled,
} = params;
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
return true;
}
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
}
export class CanvasRenderer {
private _container!: HTMLElement;
@@ -145,6 +327,19 @@ export class CanvasRenderer {
private _needsFullRender = true;
private _lastCanvasBudgetZoom = 1;
private _lastLowZoomSurvivalMode = false;
private _lastBypassStackingCanvases = false;
private _stackingCanvasesAttached = true;
private _stackingCanvasRecoveryUntil = 0;
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
null;
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
refreshCount: 0,
coalescedRefreshCount: 0,
@@ -189,6 +384,10 @@ export class CanvasRenderer {
return this._stackingCanvas;
}
get stackingCanvasesAttached() {
return this._stackingCanvasesAttached;
}
constructor(options: RendererOptions) {
const canvas = document.createElement('canvas');
@@ -196,6 +395,7 @@ export class CanvasRenderer {
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
this.std = options.std;
this.viewport = options.viewport;
this._lastCanvasBudgetZoom = this.viewport.zoom;
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
@@ -223,22 +423,28 @@ export class CanvasRenderer {
*
* It is not recommended to set width and height to 100%.
*/
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
const { width, height, viewScale } = this.viewport;
const actualWidth = Math.ceil(width * dpr);
const actualHeight = Math.ceil(height * dpr);
private _canvasSizeUpdater(
bound = this.viewport.overscanViewportBounds,
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
return {
filter({ width, height }: HTMLCanvasElement) {
return width !== actualWidth || height !== actualHeight;
filter(canvas: HTMLCanvasElement) {
return (
canvas.width !== layout.actualWidth ||
canvas.height !== layout.actualHeight ||
canvas.style.transform !== layout.transform
);
},
update(canvas: HTMLCanvasElement) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.style.transform = `scale(${1 / viewScale})`;
canvas.style.transformOrigin = `top left`;
canvas.width = actualWidth;
canvas.height = actualHeight;
applyCanvasViewportLayout(canvas, layout);
},
};
}
@@ -246,7 +452,7 @@ export class CanvasRenderer {
private _applyStackingCanvasLayout(
canvas: HTMLCanvasElement,
bound: Bound | null,
dpr = window.devicePixelRatio
dpr = getEffectiveDpr(this.viewport.zoom)
) {
const state =
this._stackingCanvasState.get(canvas) ??
@@ -270,44 +476,18 @@ export class CanvasRenderer {
return;
}
const { viewportBounds, zoom, viewScale } = this.viewport;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
const actualWidth = Math.max(1, Math.ceil(width * dpr));
const actualHeight = Math.max(1, Math.ceil(height * dpr));
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
const layout = getCanvasViewportLayout({
bound,
viewportBounds: this.viewport.viewportBounds,
zoom: this.viewport.zoom,
viewScale: this.viewport.viewScale,
dpr,
});
if (canvas.style.display !== 'block') {
canvas.style.display = 'block';
}
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== `${width}px`) {
canvas.style.width = `${width}px`;
}
if (canvas.style.height !== `${height}px`) {
canvas.style.height = `${height}px`;
}
if (canvas.style.transform !== transform) {
canvas.style.transform = transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== actualWidth) {
canvas.width = actualWidth;
}
if (canvas.height !== actualHeight) {
canvas.height = actualHeight;
}
applyCanvasViewportLayout(canvas, layout);
state.bound = bound;
state.layerId = canvas.dataset.layerId ?? null;
@@ -434,6 +614,125 @@ export class CanvasRenderer {
this._applyStackingCanvasLayout(canvas, null);
}
private _syncStackingCanvasAttachment(shouldAttach: boolean) {
const payloadDiff = getStackingCanvasAttachmentDiff({
canvases: this._stackingCanvas,
wasAttached: this._stackingCanvasesAttached,
shouldAttach,
});
this._stackingCanvasesAttached = shouldAttach;
if (!payloadDiff.added.length && !payloadDiff.removed.length) {
return;
}
this.stackingCanvasUpdated.next({
canvases: this._stackingCanvas,
...payloadDiff,
});
}
private _isStackingCanvasRecoveryActive() {
return this._stackingCanvasRecoveryUntil > performance.now();
}
private _clearStackingCanvasRecoveryTimer() {
if (this._stackingCanvasRecoveryTimerId !== null) {
clearTimeout(this._stackingCanvasRecoveryTimerId);
this._stackingCanvasRecoveryTimerId = null;
}
}
private _scheduleStackingCanvasRecoveryWindow(
delayMs = viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY
) {
this._clearStackingCanvasRecoveryTimer();
this._stackingCanvasRecoveryUntil = performance.now() + delayMs;
this._stackingCanvasRecoveryTimerId = setTimeout(() => {
this._stackingCanvasRecoveryTimerId = null;
this._stackingCanvasRecoveryUntil = 0;
if (this._container) {
this._updatePlaceholderMode();
}
}, delayMs);
}
private _syncCanvasBudgetForViewportZoom() {
const nextZoom = this.viewport.zoom;
if (
!shouldSyncCanvasBudgetOnViewportUpdate(
this._lastCanvasBudgetZoom,
nextZoom
)
) {
this._lastCanvasBudgetZoom = nextZoom;
return;
}
this._lastCanvasBudgetZoom = nextZoom;
this._resetSize();
this._render();
}
private _updatePlaceholderMode() {
const gestureActive =
this.viewport.panning$.value || this.viewport.zooming$.value;
const recoveryActive = this._isStackingCanvasRecoveryActive();
const lowZoomSurvivalMode = shouldUseLowZoomSurvivalMode(
IS_IOS,
this.viewport.zoom,
gestureActive
);
const shouldBypassStackingCanvases =
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive,
recoveryActive,
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const shouldRenderPlaceholders = shouldRenderCanvasPlaceholders({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
turboEnabled: this._turboEnabled(),
});
const bypassModeChanged =
this._lastBypassStackingCanvases !== shouldBypassStackingCanvases;
this._syncStackingCanvasAttachment(!shouldBypassStackingCanvases);
if (this.usePlaceholder === shouldRenderPlaceholders) {
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (bypassModeChanged) {
this.refresh({ type: 'all' });
}
return;
}
this.usePlaceholder = shouldRenderPlaceholders;
const survivalModeChanged =
this._lastLowZoomSurvivalMode !== lowZoomSurvivalMode;
this._lastLowZoomSurvivalMode = lowZoomSurvivalMode;
this._lastBypassStackingCanvases = shouldBypassStackingCanvases;
if (
survivalModeChanged ||
bypassModeChanged ||
!this.viewport.SKIP_REFRESH_DURING_GESTURE ||
!gestureActive
) {
this.refresh({ type: 'all' });
}
}
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager;
const updateStackingCanvas = () => {
@@ -476,7 +775,9 @@ export class CanvasRenderer {
};
if (diff > 0) {
payload.added = canvases.slice(-diff);
if (this._stackingCanvasesAttached) {
payload.added = canvases.slice(-diff);
}
} else {
payload.removed = currentCanvases.slice(diff);
payload.removed.forEach(canvas => {
@@ -485,7 +786,9 @@ export class CanvasRenderer {
});
}
this.stackingCanvasUpdated.next(payload);
if (payload.added.length || payload.removed.length) {
this.stackingCanvasUpdated.next(payload);
}
}
this.refresh({ type: 'all' });
@@ -503,41 +806,131 @@ export class CanvasRenderer {
private _initViewport() {
let sizeUpdatedRafId: number | null = null;
this._disposables.add({
dispose: () => this._clearStackingCanvasRecoveryTimer(),
});
this._disposables.add(
this.viewport.zoomUpdated.subscribe(() => {
this._syncCanvasBudgetForViewportZoom();
})
);
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this._updatePlaceholderMode();
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh({ type: 'all' });
})
);
this._disposables.add(
this.viewport.sizeUpdated.subscribe(() => {
if (
IS_IOS &&
this.viewport.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
this.viewport.width > this.viewport.height
) {
this._scheduleStackingCanvasRecoveryWindow();
if (this._container) {
this._updatePlaceholderMode();
}
}
if (sizeUpdatedRafId) return;
sizeUpdatedRafId = requestConnectedFrame(() => {
sizeUpdatedRafId = null;
this._resetSize();
this._render();
// When SKIP_REFRESH_DURING_GESTURE is active, schedule the render
// after a short delay to let the layout settle on orientation change,
// avoiding a white-flash from resizing + rendering in the same frame.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
setTimeout(() => this._render(), 16);
} else {
this._render();
}
}, this._container);
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this.refresh({ type: 'all' });
}
this.viewport.zooming$.subscribe(() => {
this._updatePlaceholderMode();
})
);
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
// while the gesture is still in-flight, but start the first recovery frame
// immediately once both gesture signals have fully settled.
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelPendingCanvasRefresh = () => {
if (pendingCanvasTimerId !== null) {
clearTimeout(pendingCanvasTimerId);
pendingCanvasTimerId = null;
}
};
const scheduleCanvasRefresh = () => {
cancelPendingCanvasRefresh();
const delayMs = getPostGestureRecoveryDelay({
isPanning: this.viewport.panning$.value,
isZooming: this.viewport.zooming$.value,
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
});
pendingCanvasTimerId = setTimeout(() => {
pendingCanvasTimerId = null;
// If a gesture is still in-flight when the timer fires, reschedule
// instead of dropping. Dropping here left connectors blank until a
// tap forced a synchronous refresh.
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
scheduleCanvasRefresh();
return;
}
this.refresh({ type: 'all' });
}, delayMs);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
this._updatePlaceholderMode();
if (panning) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
this._updatePlaceholderMode();
if (zooming) {
cancelPendingCanvasRefresh();
} else {
scheduleCanvasRefresh();
}
})
);
this._disposables.add({ dispose: cancelPendingCanvasRefresh });
}
let wasDragging = false;
this._disposables.add(
effect(() => {
const isDragging = this._gfx.tool.dragging$.value;
if (wasDragging && !isDragging) {
this.refresh({ type: 'all' });
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
// Deferred refresh will handle it after gesture ends
} else {
this.refresh({ type: 'all' });
}
}
wasDragging = isDragging;
@@ -572,16 +965,34 @@ export class CanvasRenderer {
private _render() {
const renderStart = performance.now();
const { viewportBounds, zoom } = this.viewport;
const { overscanViewportBounds, viewportBounds, zoom } = this.viewport;
const {
cullBound: mainCanvasCullBound,
renderBound: mainCanvasRenderBound,
} = getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
});
const { ctx } = this;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale);
const renderStats = this._createRenderPassStats();
const fullRender = this._needsFullRender;
const stackingIndexesToRender = fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
const bypassStackingCanvases = getStackingCanvasBypassState({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
gestureActive:
this.viewport.panning$.value || this.viewport.zooming$.value,
recoveryActive: this._isStackingCanvasRecoveryActive(),
viewportWidth: this.viewport.width,
viewportHeight: this.viewport.height,
});
const stackingIndexesToRender = bypassStackingCanvases
? []
: fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
/**
* if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
*/
let fallbackElement: SurfaceElementModel[] = [];
const allCanvasLayers = this.layerManager.getCanvasLayers();
const viewportBound = Bound.from(viewportBounds);
const stackingViewportBound = Bound.from(overscanViewportBounds);
this._canvasSizeUpdater(mainCanvasRenderBound, dpr).update(this.canvas);
if (bypassStackingCanvases) {
this._stackingCanvas.forEach(canvas => {
this._applyStackingCanvasLayout(canvas, null, dpr);
});
}
for (const idx of stackingIndexesToRender) {
const layer = allCanvasLayers[idx];
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
const layerRenderBound = this._getLayerRenderBound(
layer.elements,
viewportBound
stackingViewportBound
);
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
canvas,
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
if (fullRender || this._mainCanvasDirty) {
allCanvasLayers.forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
if (
bypassStackingCanvases ||
!this._stackingCanvas[idx] ||
this._stackingCanvas[idx].width === 0 ||
this._stackingCanvas[idx].height === 0
) {
fallbackElement = fallbackElement.concat(layer.elements);
}
});
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
mainCanvasRenderBound,
fallbackElement,
true,
renderStats
renderStats,
mainCanvasCullBound
);
}
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
bound: IBound,
surfaceElements?: SurfaceElementModel[],
overLay: boolean = false,
renderStats?: RenderPassStats
renderStats?: RenderPassStats,
cullBound: IBound = bound
) {
if (!ctx) return;
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
const elements =
surfaceElements ??
(this.grid.search(bound, {
(this.grid.search(cullBound, {
filter: ['canvas', 'local'],
}) as SurfaceElementModel[]);
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
if (display && intersects(getBoundWithRotation(element), cullBound)) {
renderStats && (renderStats.visibleElementCount += 1);
if (
this.usePlaceholder &&
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
) {
renderStats && (renderStats.placeholderElementCount += 1);
ctx.save();
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
const drawX = element.x - bound.x;
const drawY = element.y - bound.y;
ctx.fillRect(drawX, drawY, element.w, element.h);
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
}
private _resetSize() {
const sizeUpdater = this._canvasSizeUpdater();
const sizeUpdater = this._canvasSizeUpdater(
this.viewport.overscanViewportBounds
);
sizeUpdater.update(this.canvas);
this._lastCanvasBudgetZoom = this.viewport.zoom;
this._invalidate({ type: 'all' });
}
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
this._container = container;
container.append(this.canvas);
this._updatePlaceholderMode();
this._resetSize();
this.refresh({ type: 'all' });
}
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
canvas = canvas || document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr;
const actualWidth = Math.ceil(bound.w * dpr);
const actualHeight = Math.ceil(bound.h * dpr);
if (canvas.width !== actualWidth) canvas.width = actualWidth;
if (canvas.height !== actualHeight) canvas.height = actualHeight;
canvas.style.width = `${bound.w}px`;
canvas.style.height = `${bound.h}px`;
@@ -19,12 +19,14 @@ import type {
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { Subject } from 'rxjs';
import type { SurfaceElementModel } from '../element-model/base.js';
import type { DomElementRenderer } from './dom-elements/index.js';
import { DomElementRendererIdentifier } from './dom-elements/index.js';
import type { Overlay } from './overlay.js';
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
type EnvProvider = {
generateColorProperty: (color: Color, fallback?: Color) => string;
@@ -222,6 +224,12 @@ export class DomRenderer {
private _initViewport() {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this._markViewportDirty();
this.refresh();
})
@@ -242,6 +250,9 @@ export class DomRenderer {
this._disposables.add(
this.viewport.zooming$.subscribe(isZooming => {
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
return;
}
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
if (this.usePlaceholder !== shouldRenderPlaceholders) {
@@ -252,6 +263,43 @@ export class DomRenderer {
})
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
this._markViewportDirty();
this.refresh();
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this._disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this._disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this._disposables.add({ dispose: cancelRefresh });
}
this.usePlaceholder = false;
}
@@ -292,12 +340,15 @@ export class DomRenderer {
domElement = document.createElement('div');
domElement.dataset.elementId = elementModel.id;
domElement.style.position = 'absolute';
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
this._elementsMap.set(elementModel.id, domElement);
this.rootElement.append(domElement);
addedElements.push(domElement);
}
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
this.getColorScheme()
);
const geometricStyles = calculatePlaceholderRect(
elementModel,
viewportBounds,
@@ -0,0 +1,10 @@
import { type ColorScheme } from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
return getAffinePlaceholderFillColor(colorScheme);
}
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
return getSurfacePlaceholderFallback(colorScheme);
}
@@ -19,6 +19,7 @@ import {
type LocalConnectorElementModel,
type PointStyle,
} from '@blocksuite/affine-model';
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
import {
getBezierParameters,
type PointLocation,
@@ -253,7 +254,7 @@ function renderLabel(
ctx.setTransform(matrix);
if (renderer.usePlaceholder) {
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
ctx.fillRect(0, 0, w, h);
return; // Skip actual label rendering
}
@@ -10,6 +10,7 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
@@ -1,5 +1,10 @@
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '@blocksuite/affine-shared/theme';
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
import { type Viewport } from '@blocksuite/std/gfx';
import { getEffectiveDpr, type Viewport } from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
@@ -10,9 +15,13 @@ import type {
ViewportLayoutTree,
} from './types';
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
export function syncCanvasSize(
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom = 1
) {
const hostRect = host.getBoundingClientRect();
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(zoom);
canvas.style.position = 'absolute';
canvas.style.left = '0px';
canvas.style.top = '0px';
@@ -186,21 +195,21 @@ export function paintPlaceholder(
const ctx = canvas.getContext('2d');
if (!ctx || !layout) return;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(viewport.zoom);
const { overallRect } = layout;
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
const offsetX = layoutViewCoord[0];
const offsetY = layoutViewCoord[1];
const colors = [
'rgba(200, 200, 200, 0.7)',
'rgba(180, 180, 180, 0.7)',
'rgba(160, 160, 160, 0.7)',
];
const colorScheme = inferColorSchemeFromThemeMode(
document.documentElement.dataset.theme
);
const fillColor = getAffinePlaceholderFillColor(colorScheme);
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
const paintNode = (node: BlockLayoutTreeNode) => {
const { layout: nodeLayout } = node;
ctx.fillStyle = colors[depth % colors.length];
ctx.fillStyle = fillColor;
const rect = nodeLayout.rect;
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
@@ -209,12 +218,12 @@ export function paintPlaceholder(
ctx.fillRect(x, y, width, height);
if (width > 10 && height > 5) {
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
ctx.strokeStyle = strokeColor;
ctx.strokeRect(x, y, width, height);
}
if (node.children.length > 0) {
node.children.forEach(childNode => paintNode(childNode, depth + 1));
node.children.forEach(childNode => paintNode(childNode));
}
};
@@ -1,11 +1,14 @@
import type { Container } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_IOS } from '@blocksuite/global/env';
import { ConfigExtensionFactory } from '@blocksuite/std';
import {
getEffectiveDpr,
type GfxController,
GfxExtension,
GfxExtensionIdentifier,
type GfxViewportElement,
viewportRuntimeConfig,
} from '@blocksuite/std/gfx';
import {
BehaviorSubject,
@@ -34,6 +37,26 @@ import type {
} from './types';
const debug = false; // Toggle for debug logs
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
export function shouldPreferBitmapCacheDuringLowZoomGesture(params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) {
return (
params.isIOS &&
params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
params.hasBitmap
);
}
export function shouldIdleTurboBlocksDuringZooming(params: {
isIOS: boolean;
zoom: number;
}) {
return !(params.isIOS && params.zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD);
}
const defaultOptions = {
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
@@ -147,7 +170,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.viewport.elementReady.pipe(take(1)).subscribe(element => {
this.viewportElement = element;
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.state$.next('pending');
this.disposables.add(
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
this.disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
if (
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
(this.viewport.panning$.value || this.viewport.zooming$.value)
) {
return;
}
this.refresh().catch(console.error);
})
);
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
tap(isZooming => {
this.debugLog(`Zooming signal changed: ${isZooming}`);
if (isZooming) {
this.state$.next('zooming');
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
this.state$.next('zooming');
}
} else if (this.state$.value === 'zooming') {
this.clearOptimizedBlocks();
this.isRecentlyZoomed$.next(true);
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
)
.subscribe()
);
// Post-gesture refresh for SKIP mode
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
const cancelRefresh = () => {
if (pendingTimerId !== null) {
clearTimeout(pendingTimerId);
pendingTimerId = null;
}
};
const scheduleRefresh = () => {
cancelRefresh();
pendingTimerId = setTimeout(() => {
pendingTimerId = null;
if (
!this.viewport.panning$.value &&
!this.viewport.zooming$.value
) {
this.refresh().catch(console.error);
}
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
};
this.disposables.add(
this.viewport.panning$.subscribe(panning => {
if (panning) cancelRefresh();
else if (!this.viewport.zooming$.value) scheduleRefresh();
})
);
this.disposables.add(
this.viewport.zooming$.subscribe(zooming => {
if (zooming) cancelRefresh();
else if (!this.viewport.panning$.value) scheduleRefresh();
})
);
this.disposables.add({ dispose: cancelRefresh });
}
});
// Handle selection and block updates
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
nextState = 'pending';
this.clearOptimizedBlocks();
} else if (this.isZooming()) {
this.debugLog('Currently zooming, using placeholder rendering');
nextState = 'zooming';
this.paintPlaceholder();
this.updateOptimizedBlocks();
if (
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
hasBitmap: !!this.bitmap,
})
) {
this.debugLog('Currently zooming, reusing cached bitmap');
this.clearOptimizedBlocks();
this.drawCachedBitmap();
} else {
this.debugLog('Currently zooming, using placeholder rendering');
this.paintPlaceholder();
this.updateOptimizedBlocks();
}
} else if (this.canUseBitmapCache()) {
this.debugLog('Using cached bitmap');
nextState = 'ready';
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
}
const layout = this.layoutCache;
const dpr = window.devicePixelRatio;
const dpr = getEffectiveDpr(this.viewport.zoom);
const currentVersion = this.layoutVersion;
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
layout.overallRect.y
);
const dpr = getEffectiveDpr(this.viewport.zoom);
ctx.drawImage(
bitmap,
layoutViewCoord[0] * window.devicePixelRatio,
layoutViewCoord[1] * window.devicePixelRatio,
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
layoutViewCoord[0] * dpr,
layoutViewCoord[1] * dpr,
layout.overallRect.w * dpr * this.viewport.zoom,
layout.overallRect.h * dpr * this.viewport.zoom
);
this.debugLog('Bitmap drawn to canvas');
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private updateOptimizedBlocks() {
if (!this.canOptimize()) return;
if (
!shouldIdleTurboBlocksDuringZooming({
isIOS: IS_IOS,
zoom: this.viewport.zoom,
})
) {
this.clearOptimizedBlocks();
return;
}
requestAnimationFrame(() => {
if (!this.viewportElement || !this.layoutCache) return;
const blockElements = this.viewportElement.getModelsInViewport();
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
private handleResize() {
this.debugLog('Container resized, syncing canvas size');
syncCanvasSize(this.canvas, this.std.host);
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
this.invalidate();
this.refresh$.next();
}
@@ -7,6 +7,7 @@
},
"include": ["./src"],
"references": [
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
@@ -1 +1,2 @@
export * from './css-variables.js';
export * from './placeholder-style.js';
@@ -0,0 +1,19 @@
import { ColorScheme } from '@blocksuite/affine-model';
export function inferColorSchemeFromThemeMode(
themeMode?: string | null
): ColorScheme {
return themeMode === 'dark' ? ColorScheme.Dark : ColorScheme.Light;
}
export function getAffinePlaceholderFillColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.04)';
}
export function getAffinePlaceholderStrokeColor(colorScheme: ColorScheme) {
return colorScheme === ColorScheme.Dark
? 'rgba(255, 255, 255, 0.04)'
: 'rgba(0, 0, 0, 0.02)';
}
@@ -2,12 +2,14 @@ import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
} from '.';
import { MobileZoomRuler } from './mobile-zoom-ruler';
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
import { EdgelessZoomToolbar } from './zoom-toolbar';
export function effects() {
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
customElements.define('mobile-zoom-ruler', MobileZoomRuler);
customElements.define(
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget
@@ -1,5 +1,6 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { RootBlockModel } from '@blocksuite/affine-model';
import { IS_MOBILE } from '@blocksuite/global/env';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
@@ -14,15 +15,20 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
static override styles = css`
:host {
position: absolute;
bottom: 20px;
bottom: var(--affine-edgeless-zoom-toolbar-bottom, 20px);
left: 12px;
z-index: var(--affine-z-index-popover);
display: flex;
justify-content: center;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
mobile-zoom-ruler {
pointer-events: auto;
}
@container viewport (width <= 1200px) {
edgeless-zoom-toolbar {
display: none;
@@ -73,10 +79,14 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
}
override render() {
if (this._hide || !this.edgeless) {
if (this._hide) {
return nothing;
}
if (IS_MOBILE) {
return html`<mobile-zoom-ruler .std=${this.std}></mobile-zoom-ruler>`;
}
return html`
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
@@ -0,0 +1,139 @@
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import { ViewBarIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
/**
* Compact zoom indicator for narrow / mobile edgeless viewports.
* Shows the live zoom percentage and a fit-to-screen action in a pill HUD
* anchored to the bottom-left of the canvas.
*/
export class MobileZoomRuler extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
pointer-events: auto;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.zoom-pill {
display: flex;
align-items: center;
height: 32px;
background: var(--affine-background-overlay-panel-color);
border: 1px solid var(--affine-border-color);
border-radius: 999px;
box-shadow: var(--affine-shadow-1);
overflow: hidden;
}
.zoom-label {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--affine-text-secondary-color);
white-space: nowrap;
user-select: none;
}
.divider {
width: 1px;
height: 16px;
background: var(--affine-border-color);
flex-shrink: 0;
}
.fit-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 100%;
padding: 0;
border: none;
background: transparent;
color: var(--affine-icon-color);
cursor: pointer;
}
.fit-button:hover:not(:disabled) {
background: var(--affine-hover-color);
color: var(--affine-primary-color);
}
.fit-button:disabled {
cursor: not-allowed;
color: var(--affine-text-disable-color);
}
.fit-button svg {
width: 20px;
height: 20px;
}
`;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get viewport() {
return this.gfx.viewport;
}
get zoom() {
if (!this.viewport) {
return 1;
}
return this.viewport.zoom;
}
override firstUpdated() {
const { disposables } = this;
const viewport = this.viewport;
if (!viewport) {
return;
}
disposables.add(
viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
disposables.add(viewport.zoomUpdated.subscribe(() => this.requestUpdate()));
}
override render() {
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
const locked = this.viewport?.locked || this.std.store.readonly;
return html`
<div
class="zoom-pill"
@dblclick=${stopPropagation}
@mousedown=${stopPropagation}
@mouseup=${stopPropagation}
@pointerdown=${stopPropagation}
>
<span class="zoom-label">${formattedZoom}</span>
<span class="divider"></span>
<button
class="fit-button"
aria-label="Fit to screen"
?disabled=${locked}
@click=${() => this.gfx.fitToScreen()}
>
${ViewBarIcon()}
</button>
</div>
`;
}
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -28,6 +28,7 @@
- [gfxGroupCompatibleSymbol](variables/gfxGroupCompatibleSymbol.md)
- [SURFACE\_TEXT\_UNIQ\_IDENTIFIER](variables/SURFACE_TEXT_UNIQ_IDENTIFIER.md)
- [SURFACE\_YMAP\_UNIQ\_IDENTIFIER](variables/SURFACE_YMAP_UNIQ_IDENTIFIER.md)
- [viewportRuntimeConfig](variables/viewportRuntimeConfig.md)
## Functions
@@ -39,6 +40,7 @@
- [generateKeyBetween](functions/generateKeyBetween.md)
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
- [generateNKeysBetween](functions/generateNKeysBetween.md)
- [getEffectiveDpr](functions/getEffectiveDpr.md)
- [getTopElements](functions/getTopElements.md)
- [GfxCompatible](functions/GfxCompatible.md)
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
@@ -0,0 +1,28 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / getEffectiveDpr
# Function: getEffectiveDpr()
> **getEffectiveDpr**(`zoom`, `rawDpr`): `number`
Resolves the effective device-pixel-ratio for canvas backing stores at the
given zoom, honoring [viewportRuntimeConfig.CANVAS\_DPR\_CAP\_BY\_ZOOM](../variables/viewportRuntimeConfig.md#canvas_dpr_cap_by_zoom).
Returns the raw `window.devicePixelRatio` when no cap applies.
## Parameters
### zoom
`number`
### rawDpr
`number` = `window.devicePixelRatio`
## Returns
`number`
@@ -0,0 +1,117 @@
[**BlockSuite API Documentation**](../../../../README.md)
***
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / viewportRuntimeConfig
# Variable: viewportRuntimeConfig
> `const` **viewportRuntimeConfig**: `object`
Process-wide defaults applied to every 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.
## Type Declaration
### CANVAS\_DPR\_CAP\_BY\_ZOOM
> **CANVAS\_DPR\_CAP\_BY\_ZOOM**: \[`number`, `number`\][]
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.
### LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT**: `number` = `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\_DISTANCE\_RATIO
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO**: `number` = `0.35`
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.
### OVERSCAN\_RATIO
> **OVERSCAN\_RATIO**: `number` = `0`
Fraction by which the *render/activation* viewport bound is enlarged on
every side (see 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 Viewport.overscanViewportBounds identical to
Viewport.viewportBounds, leaving desktop behavior unchanged.
This governs the *canvas* render bound only (see
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\_BLOCK
> **OVERSCAN\_RATIO\_BLOCK**: `number` = `0`
Like [OVERSCAN\_RATIO](#overscan_ratio) but for the *DOM block mounting* bound (see
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
[OVERSCAN\_RATIO](#overscan_ratio) is generous. `0` (desktop default) leaves block
mounting on the exact visible bound, unchanged from upstream.
### POST\_GESTURE\_REFRESH\_DELAY
> **POST\_GESTURE\_REFRESH\_DELAY**: `number` = `800`
Delay (ms) before the post-gesture refresh repaints canvases and reactivates
blocks, used only when [SKIP\_REFRESH\_DURING\_GESTURE](#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.
### SKIP\_REFRESH\_DURING\_GESTURE
> **SKIP\_REFRESH\_DURING\_GESTURE**: `boolean` = `false`
### VIEWPORT\_REFRESH\_MAX\_INTERVAL
> **VIEWPORT\_REFRESH\_MAX\_INTERVAL**: `number` = `120`
### VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD
> **VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD**: `number` = `18`
### ZOOM\_MAX
> **ZOOM\_MAX**: `number`
### ZOOM\_MIN
> **ZOOM\_MIN**: `number`
@@ -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(
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Memento } from '@toeverything/infra';
import {
@@ -115,6 +116,9 @@ export class PersistentJSONFileStorage implements Memento {
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
await fs.promises.mkdir(path.dirname(this.filepath), {
recursive: true,
});
await fs.promises.writeFile(
this.filepath,
JSON.stringify(this.data, null, 2),
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -88,8 +88,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Plugins;
sourceTree = "<group>";
};
@@ -309,9 +307,13 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
@@ -504,7 +506,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 73YMMDVT2M;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
@@ -517,7 +519,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 0.26.3;
MARKETING_VERSION = 0.26.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -540,7 +542,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 73YMMDVT2M;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
@@ -553,7 +555,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 0.26.3;
MARKETING_VERSION = 0.26.5;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1,31 +1,55 @@
import Capacitor
import Intelligents
import UIKit
import WebKit
class AFFiNEViewController: CAPBridgeViewController {
class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate {
var intelligentsButton: IntelligentsButton?
override func viewDidLoad() {
super.viewDidLoad()
webView?.allowsBackForwardNavigationGestures = true
webView?.allowsBackForwardNavigationGestures = false
navigationController?.navigationBar.isHidden = true
extendedLayoutIncludesOpaqueBars = false
edgesForExtendedLayout = []
// Disable WKWebView scrollView zoom/bounce to prevent conflict with edgeless canvas gestures
webView?.scrollView.minimumZoomScale = 1.0
webView?.scrollView.maximumZoomScale = 1.0
webView?.scrollView.bouncesZoom = false
webView?.scrollView.bounces = false
webView?.scrollView.pinchGestureRecognizer?.isEnabled = false
webView?.scrollView.delegate = self
// Inject viewport meta to prevent WKWebView smart zoom
let viewportScript = """
(function() {
function setViewport() {
var meta = document.querySelector('meta[name="viewport"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'viewport';
(document.head || document.documentElement).appendChild(meta);
}
meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover';
}
if (document.head) {
setViewport();
} else {
document.addEventListener('DOMContentLoaded', setViewport);
}
})();
"""
webView?.configuration.userContentController.addUserScript(
WKUserScript(source: viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
)
let intelligentsButton = installIntelligentsButton()
intelligentsButton.delegate = self
self.intelligentsButton = intelligentsButton
dismissIntelligentsButton()
}
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let configuration = super.webViewConfiguration(for: instanceConfiguration)
return configuration
}
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
super.webView(with: frame, configuration: configuration)
}
override func capacitorDidLoad() {
let plugins: [CAPPlugin] = [
AuthPlugin(),
@@ -56,7 +80,7 @@ class AFFiNEViewController: CAPBridgeViewController {
private func checkEligibilityOfIntelligent() {
guard !isCheckingIntelligentEligibility else { return }
assert(intelligentsButton != nil)
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
guard intelligentsButton?.isHidden ?? false else { return }
isCheckingIntelligentEligibility = true
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [self] result in
@@ -75,4 +99,31 @@ class AFFiNEViewController: CAPBridgeViewController {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return nil
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
scrollView.zoomScale = 1.0
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset != .zero {
scrollView.contentOffset = .zero
}
}
// MARK: - Web Content Process Crash Recovery
// NOTE: Capacitor's CAPBridgeViewController owns the WKWebView
// navigationDelegate (it assigns its own WebViewDelegationHandler), so this
// override is NOT called in practice Capacitor's handler logs
// " WebView process terminated" and reloads instead. Kept as defensive
// fallback, matching the prior baseline behavior.
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
webView.reload()
}
}
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>10</string>
<string>17</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
@@ -42,7 +42,7 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
<key>NSUserTrackingUsageDescription</key>
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
<string>Rest assured, enabling this permission won&apos;t access your private info on other sites. It&apos;s only used to identify your device and improve security and product experience.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIImageName</key>
@@ -69,10 +69,10 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -171,16 +171,16 @@ private extension ChatManager {
let uploadableAttachments: [CopilotAttachmentUpload] = [
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
.init(
originalName: file.name,
data: file.data ?? .init(),
mimeType: mimeType(text: file.name),
data: file.data ?? .init()
originalName: file.name
)
},
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
.init(
originalName: "image.jpg",
data: image.imageData,
mimeType: mimeType(pathExtension: "jpg"),
data: image.imageData
originalName: "image.jpg"
)
},
].flatMap(\.self)
+6 -6
View File
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: a5bf59e09f9dd82694fdcca4d107b4d215ac470f
CapacitorApp: 3ddbd30ac18c321531c3da5e707b60873d89dd60
CapacitorBrowser: 66aa8ff09cdca2a327ce464b113b470e6f667753
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
CapacitorKeyboard: 45cae3956a6f4fb1753f9a4df3e884aeaed8fe82
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
@@ -21,6 +21,8 @@ const config: CapacitorConfig & AppConfig = {
scheme: 'AFFiNE',
path: '.',
webContentsDebuggingEnabled: true,
// Silence Capacitor's bridge logging (⚡️ TO JS / ⚡️ To Native -> / ⚡️ [log]).
loggingBehavior: 'none',
},
server: {
// url: 'http://localhost:8080',
+85
View File
@@ -1,3 +1,88 @@
import '@affine/core/bootstrap/browser';
import '@affine/core/bootstrap/cleanup';
import './proxy';
import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx';
// iOS WKWebView terminates the web content process when edgeless compositing
// memory (GPU-side IOSurface tiles) spikes. Two distinct triggers exist:
// 1. Resting canvas pixel memory — bounded by CANVAS_DPR_CAP_BY_ZOOM below.
// 2. The transient GPU/DOM churn of a *fast* gesture at extreme zoom-out,
// where the whole document composites at once.
//
// These overrides are applied once at module load, before any editor or
// readonly preview mounts, so every Viewport instance is constructed with the
// mobile-safe limits. Setting them at construction (rather than mutating a live
// Viewport afterward) avoids both the race condition and the wrong-instance
// problem that previously left the preview viewport on desktop defaults.
//
// Strategy (multi-layer, stability first):
// - The dpr cap (below) is the real memory lever: canvas backing-store memory
// scales with dpr^2, so forcing dpr 1 across the zoom-out range is what
// keeps the compositing budget bounded and stops the web process crashing.
// - ZOOM_MIN 0.4 bounds how small content can get (and keeps the live zoom in
// the dpr-1 bucket); it is a guardrail, not the primary crash fix.
// - OVERSCAN_RATIO pre-rasterizes a margin around the visible area on the
// *canvas* path, so a pan/zoom moves into content that is *already* painted
// instead of blanking out and waiting for the post-gesture refresh. This is
// what fixes "connectors/elements vanish for 1-2s". Canvas overscan grows
// backing-store area, so keep it modest and rely on the dpr cap to bound
// mobile memory.
// - OVERSCAN_RATIO_BLOCK is the *separate* knob for DOM block mounting, which
// is expensive: each mounted block is its own composited layer subtree, so
// enlarging this multiplies resident memory and is what drives the iOS
// jetsam kill. On-device diagnostics showed the active-block count doubling
// (~16 → ~32) right before each crash when block mounting shared the wide
// canvas margin. Kept at 0 so blocks mount on the exact visible bound while
// connectors still pre-paint via the wider canvas margin above.
viewportRuntimeConfig.ZOOM_MIN = 0.4;
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60;
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300;
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true;
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
// Pre-paint a 20% margin on every side of the viewport for the *canvas* render
// path. This keeps nearby connectors/shapes warm during orientation and zoom
// gestures, but trims the backing-store and paint budget versus the previous
// 35% setting so low-zoom survival mode has less work to recover from on iOS.
viewportRuntimeConfig.OVERSCAN_RATIO = 0.2;
// Keep DOM block mounting on the exact visible bound (no overscan). Each mounted
// block adds a composited layer subtree to the WebContent process; widening this
// is what doubled the active-block count (~16 → ~32) and triggered the iOS
// jetsam memory kill in on-device logs. Connectors/elements still pre-paint via
// the generous canvas OVERSCAN_RATIO above, so visibility is preserved without
// paying the block-mounting memory cost.
viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0;
// After a gesture ends, blocks and canvases are repainted once. The default
// 800ms felt sluggish on device (elements/connectors took ~3-4s to settle once
// the trailing 200ms panning/zooming debounce and rescheduling were factored
// in). Shorten the post-gesture refresh so the total settle time — ~200ms
// debounce + this delay — lands under ~500ms. Both the canvas and block timers
// read this same value, so connectors and elements reappear together instead of
// staggered.
viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220;
// At far-out zoom each block is tiny on screen, so a full retina backing store
// (width * devicePixelRatio) is wasted pixels — and on iOS that waste is what
// pushes WKWebView's compositing budget over the edge and crashes the web
// content process during pan. Cap the canvas backing-store dpr the further out
// we zoom: the smaller the content, the less resolution it needs.
//
// Canvas memory scales with backing-store area and dpr^2. With a fixed viewport
// and overscan ratio, the dpr bucket dominates: on-device diagnostics showed
// zoom 0.4 with dpr 2 hitting ~7.2 mp and crashing on the first fast zoom-out;
// the same scene at dpr 1 sits near ~1.8 mp and is stable. Raising ZOOM_MIN
// alone cannot fix it — it only moves the zoom between buckets.
//
// Therefore force dpr 1 for the entire mobile zoom-out range (zoom < 0.5, which
// covers the 0.4 floor) to keep the compositing budget bounded. Connectors are
// slightly thinner at the floor as a result; stability is the hard requirement
// here, so that trade is accepted. dpr 2 is kept for the near-1.0 range where
// content is large and crispness matters. Buckets are checked low-to-high; the
// first matching `zoom < threshold` wins.
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
@@ -19,9 +19,11 @@ import type { AppTabLink } from './type';
export const AppTabs = ({
background,
fixed = true,
hidden = false,
}: {
background?: string;
fixed?: boolean;
hidden?: boolean;
}) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
@@ -47,7 +49,8 @@ export const AppTabs = ({
...assignInlineVars({
[styles.appTabsBackground]: background,
}),
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
visibility: hidden || virtualKeyboardVisible ? 'hidden' : 'visible',
pointerEvents: hidden || virtualKeyboardVisible ? 'none' : 'auto',
}}
>
<ul className={styles.appTabsInner} role="tablist">
@@ -8,6 +8,13 @@ export const root = style({
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
selectors: {
'&:has([data-mode="edgeless"])': {
height: '100dvh',
maxHeight: '100dvh',
overflow: 'hidden',
},
},
});
export const header = style({
@@ -76,6 +83,10 @@ export const affineDocViewport = style({
left: 0,
right: 0,
bottom: 0,
containerType: 'normal',
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
},
},
});
@@ -122,3 +133,27 @@ export const journalIconButton = style({
export const journalDatePicker = style({
background: cssVarV2('layer/background/primary'),
});
// When edgeless mode is active, prevent document-level scrolling
// so native scrollView pan gestures don't scroll the page away from the canvas
globalStyle('html:has([data-lock-document-scroll="true"])', {
overflow: 'hidden',
height: '100dvh',
overscrollBehavior: 'none',
});
globalStyle('body:has([data-lock-document-scroll="true"])', {
height: '100dvh',
minHeight: '100dvh',
overflow: 'hidden',
overscrollBehavior: 'none',
});
globalStyle('body:has([data-lock-document-scroll="true"]):has(>#app-tabs)', {
paddingBottom: 0,
});
// Prevent native touch handling on edgeless viewport so canvas handles all gestures
globalStyle('[data-mode="edgeless"] .affine-edgeless-viewport', {
touchAction: 'none',
});
@@ -0,0 +1,190 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import * as immersiveModule from './mobile-detail-page.immersive';
import {
getImmersiveZoomToolbarBottom,
isImmersiveTapTarget,
isLandscapeWindow,
isTapWithinSlop,
shouldEnableEdgelessImmersive,
shouldLockEdgelessDocumentScroll,
shouldShowMobileDetailPageTitle,
shouldTrackMobileDetailPageTitleScroll,
} from './mobile-detail-page.immersive';
describe('mobile detail page immersive helpers', () => {
test('enables immersive mode only for edgeless landscape', () => {
expect(
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: true })
).toBe(true);
expect(
shouldEnableEdgelessImmersive({ mode: 'page', isLandscape: true })
).toBe(false);
expect(
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: false })
).toBe(false);
});
test('treats window as landscape only when media query and geometry agree', () => {
expect(
isLandscapeWindow({
width: 844,
height: 390,
matchesLandscape: true,
})
).toBe(true);
expect(
isLandscapeWindow({
width: 390,
height: 844,
matchesLandscape: true,
})
).toBe(false);
expect(
isLandscapeWindow({
width: 844,
height: 390,
matchesLandscape: false,
})
).toBe(false);
});
test('marks mismatched orientation signals as unsettled so immersive mode can retry the first rotation sample', () => {
expect('getLandscapeWindowMeasurement' in immersiveModule).toBe(true);
const getLandscapeWindowMeasurement = (
immersiveModule as {
getLandscapeWindowMeasurement: (params: {
width: number;
height: number;
matchesLandscape: boolean;
}) => {
isLandscape: boolean;
settled: boolean;
};
}
).getLandscapeWindowMeasurement;
expect(
getLandscapeWindowMeasurement({
width: 844,
height: 390,
matchesLandscape: false,
})
).toEqual({
isLandscape: false,
settled: false,
});
expect(
getLandscapeWindowMeasurement({
width: 390,
height: 844,
matchesLandscape: true,
})
).toEqual({
isLandscape: false,
settled: false,
});
expect(
getLandscapeWindowMeasurement({
width: 844,
height: 390,
matchesLandscape: true,
})
).toEqual({
isLandscape: true,
settled: true,
});
});
test('ignores taps from edgeless toolbar chrome targets', () => {
const toolbar = document.createElement('edgeless-toolbar-widget');
const toolbarButton = document.createElement('button');
toolbar.append(toolbarButton);
const zoomToolbar = document.createElement('div');
zoomToolbar.className = 'edgeless-zoom-toolbar-container';
const zoomButton = document.createElement('button');
zoomToolbar.append(zoomButton);
const selectedRect = document.createElement('div');
selectedRect.className = 'affine-edgeless-selected-rect';
const resizeHandle = document.createElement('div');
selectedRect.append(resizeHandle);
const canvas = document.createElement('div');
document.body.append(toolbar, zoomToolbar, selectedRect, canvas);
expect(isImmersiveTapTarget(toolbarButton)).toBe(false);
expect(isImmersiveTapTarget(zoomButton)).toBe(false);
expect(isImmersiveTapTarget(resizeHandle)).toBe(false);
expect(isImmersiveTapTarget(canvas)).toBe(true);
expect(isImmersiveTapTarget(null)).toBe(false);
});
test('accepts only small pointer movement as a tap', () => {
expect(
isTapWithinSlop(
{ clientX: 100, clientY: 200 },
{ clientX: 104, clientY: 205 }
)
).toBe(true);
expect(
isTapWithinSlop(
{ clientX: 100, clientY: 200 },
{ clientX: 120, clientY: 205 }
)
).toBe(false);
});
test('raises zoom toolbar above tab bar only when immersive chrome is visible', () => {
expect(
getImmersiveZoomToolbarBottom({
immersive: true,
chromeVisible: false,
})
).toBe('10px');
expect(
getImmersiveZoomToolbarBottom({
immersive: true,
chromeVisible: true,
tabBarOffset: 'var(--appTabSafeArea)',
})
).toBe('calc(10px + var(--appTabSafeArea))');
expect(
getImmersiveZoomToolbarBottom({
immersive: false,
chromeVisible: true,
tabBarOffset: 'var(--appTabSafeArea)',
})
).toBeUndefined();
});
test('locks document scroll whenever edgeless mode is active', () => {
expect(shouldLockEdgelessDocumentScroll('edgeless')).toBe(true);
expect(shouldLockEdgelessDocumentScroll('page')).toBe(false);
});
test('tracks title scroll only in page mode', () => {
expect(shouldTrackMobileDetailPageTitleScroll('page')).toBe(true);
expect(shouldTrackMobileDetailPageTitleScroll('edgeless')).toBe(false);
});
test('shows title only after crossing the existing scroll threshold', () => {
expect(shouldShowMobileDetailPageTitle(157)).toBe(false);
expect(shouldShowMobileDetailPageTitle(158)).toBe(true);
expect(shouldShowMobileDetailPageTitle(240)).toBe(true);
});
});
@@ -0,0 +1,105 @@
export const EDGELESS_IMMERSIVE_TAP_SLOP = 8;
const IMMERSIVE_TAP_EXCLUDE_SELECTORS = [
'edgeless-toolbar-widget',
'.edgeless-toolbar-container',
'affine-edgeless-zoom-toolbar-widget',
'.edgeless-zoom-toolbar-container',
'.affine-edgeless-selected-rect',
].join(', ');
export function getLandscapeWindowMeasurement({
width,
height,
matchesLandscape,
}: {
width: number;
height: number;
matchesLandscape: boolean;
}) {
const geometryLandscape = width > height;
return {
isLandscape: matchesLandscape && geometryLandscape,
settled: matchesLandscape === geometryLandscape,
};
}
export function isLandscapeWindow({
width,
height,
matchesLandscape,
}: {
width: number;
height: number;
matchesLandscape: boolean;
}) {
return getLandscapeWindowMeasurement({
width,
height,
matchesLandscape,
}).isLandscape;
}
export function shouldEnableEdgelessImmersive({
mode,
isLandscape,
}: {
mode: 'page' | 'edgeless';
isLandscape: boolean;
}) {
return mode === 'edgeless' && isLandscape;
}
export function shouldLockEdgelessDocumentScroll(mode: 'page' | 'edgeless') {
return mode === 'edgeless';
}
export function shouldTrackMobileDetailPageTitleScroll(
mode: 'page' | 'edgeless'
) {
return mode === 'page';
}
export function shouldShowMobileDetailPageTitle(scrollY: number) {
return scrollY >= 158;
}
export function isImmersiveTapTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false;
}
return !target.closest(IMMERSIVE_TAP_EXCLUDE_SELECTORS);
}
export function isTapWithinSlop(
start: { clientX: number; clientY: number },
end: { clientX: number; clientY: number },
slop = EDGELESS_IMMERSIVE_TAP_SLOP
) {
return (
Math.abs(start.clientX - end.clientX) <= slop &&
Math.abs(start.clientY - end.clientY) <= slop
);
}
export function getImmersiveZoomToolbarBottom({
immersive,
chromeVisible,
tabBarOffset,
}: {
immersive: boolean;
chromeVisible: boolean;
tabBarOffset?: string;
}) {
if (!immersive) {
return undefined;
}
if (chromeVisible && tabBarOffset) {
return `calc(10px + ${tabBarOffset})`;
}
return '10px';
}
@@ -8,7 +8,6 @@ import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-he
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
import { PageHeader } from '@affine/core/mobile/components';
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
import { AIButtonService } from '@affine/core/modules/ai-button';
import { ServerService } from '@affine/core/modules/cloud';
import { DocService } from '@affine/core/modules/doc';
@@ -36,17 +35,44 @@ import {
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { globalVars } from '../../../styles/variables.css';
import { JournalConflictBlock } from './journal-conflict-block';
import { JournalDatePicker } from './journal-date-picker';
import * as styles from './mobile-detail-page.css';
import {
getImmersiveZoomToolbarBottom,
getLandscapeWindowMeasurement,
isImmersiveTapTarget,
isLandscapeWindow,
isTapWithinSlop,
shouldEnableEdgelessImmersive,
shouldLockEdgelessDocumentScroll,
shouldShowMobileDetailPageTitle,
shouldTrackMobileDetailPageTitleScroll,
} from './mobile-detail-page.immersive';
import { PageHeaderMenuButton } from './page-header-more-button';
import { PageHeaderShareButton } from './page-header-share-button';
const DetailPageImpl = () => {
type ImmersiveTapHandlers = {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: () => void;
};
const DetailPageImpl = ({
immersive,
chromeVisible,
immersiveTapHandlers,
}: {
immersive: boolean;
chromeVisible: boolean;
immersiveTapHandlers?: ImmersiveTapHandlers;
}) => {
const {
editorService,
docService,
@@ -170,7 +196,7 @@ const DetailPageImpl = () => {
editor.bindEditorContainer(
editorContainer,
(editorContainer as any).docTitle, // set from proxy
editorContainer.docTitle,
scrollViewportRef.current
);
@@ -189,19 +215,36 @@ const DetailPageImpl = () => {
!enableKeyboardToolbar ||
(mode === 'edgeless' && !enableEdgelessEditing);
const immersiveZoomToolbarBottom = getImmersiveZoomToolbarBottom({
immersive,
chromeVisible,
tabBarOffset: globalVars.appTabSafeArea,
});
const lockDocumentScroll = shouldLockEdgelessDocumentScroll(mode);
const immersiveViewportStyle = immersiveZoomToolbarBottom
? ({
'--affine-edgeless-zoom-toolbar-bottom': immersiveZoomToolbarBottom,
} as CSSProperties)
: undefined;
return (
<FrameworkScope scope={editor.scope}>
<div className={styles.mainContainer}>
<div
data-mode={mode}
data-lock-document-scroll={lockDocumentScroll ? 'true' : undefined}
ref={scrollViewportRef}
style={immersiveViewportStyle}
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
)}
onPointerDown={immersiveTapHandlers?.onPointerDown}
onPointerUp={immersiveTapHandlers?.onPointerUp}
onPointerCancel={immersiveTapHandlers?.onPointerCancel}
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
</AffineErrorBoundary>
@@ -228,7 +271,262 @@ const skeletonWithBack = getSkeleton(true);
const notFound = getNotFound(false);
const notFoundWithBack = getNotFound(true);
const checkShowTitle = () => window.scrollY >= 158;
const getShouldShowTitle = () =>
shouldShowMobileDetailPageTitle(window.scrollY);
const LANDSCAPE_MEASUREMENT_MAX_RETRIES = 4;
const getIsLandscape = () =>
isLandscapeWindow({
width: window.innerWidth,
height: window.innerHeight,
matchesLandscape: window.matchMedia('(orientation: landscape)').matches,
});
const MobileDetailPageHeader = ({
date,
fromTab,
title,
allJournalDates,
handleDateChange,
trackScrollTitle,
}: {
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
trackScrollTitle: boolean;
}) => {
const [showTitle, setShowTitle] = useState(getShouldShowTitle);
useEffect(() => {
if (!trackScrollTitle) {
return;
}
let frame = 0;
const updateShowTitle = () => {
frame = 0;
setShowTitle(prev => {
const next = getShouldShowTitle();
return prev === next ? prev : next;
});
};
const handleScroll = () => {
if (frame) {
return;
}
frame = window.requestAnimationFrame(updateShowTitle);
};
window.addEventListener('scroll', handleScroll);
handleScroll();
return () => {
if (frame) {
window.cancelAnimationFrame(frame);
}
window.removeEventListener('scroll', handleScroll);
};
}, [trackScrollTitle]);
return (
<PageHeader
back={!fromTab}
className={styles.header}
contentClassName={styles.headerContent}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
bottom={
date ? (
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
) : null
}
bottomSpacer={94}
>
<span data-show={!!date || showTitle} className={styles.headerTitle}>
{date
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
: title}
</span>
</PageHeader>
);
};
const MobileDetailPageContent = ({
pageId,
date,
fromTab,
title,
allJournalDates,
handleDateChange,
}: {
pageId: string;
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
}) => {
const editor = useService(EditorService).editor;
const mode = useLiveData(editor.mode$);
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [chromeVisible, setChromeVisible] = useState(true);
const tapStateRef = useRef<{
pointerId: number;
clientX: number;
clientY: number;
tappable: boolean;
} | null>(null);
const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape });
const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode);
useEffect(() => {
const mediaQuery = window.matchMedia('(orientation: landscape)');
let frame = 0;
let disposed = false;
let remainingRetries = 0;
const sampleLandscape = () => {
frame = 0;
if (disposed) {
return;
}
const measurement = getLandscapeWindowMeasurement({
width: window.innerWidth,
height: window.innerHeight,
matchesLandscape: mediaQuery.matches,
});
setIsLandscape(prev => {
const next = measurement.isLandscape;
return prev === next ? prev : next;
});
if (!measurement.settled && remainingRetries > 0) {
remainingRetries -= 1;
frame = window.requestAnimationFrame(sampleLandscape);
}
};
const updateLandscape = () => {
if (frame) {
window.cancelAnimationFrame(frame);
}
remainingRetries = LANDSCAPE_MEASUREMENT_MAX_RETRIES;
frame = window.requestAnimationFrame(sampleLandscape);
};
updateLandscape();
window.addEventListener('resize', updateLandscape);
mediaQuery.addEventListener('change', updateLandscape);
return () => {
disposed = true;
if (frame) {
window.cancelAnimationFrame(frame);
}
window.removeEventListener('resize', updateLandscape);
mediaQuery.removeEventListener('change', updateLandscape);
};
}, []);
useEffect(() => {
setChromeVisible(!immersive);
tapStateRef.current = null;
}, [immersive, pageId]);
useEffect(() => {
if (!immersive || !chromeVisible) {
return;
}
const timeout = window.setTimeout(() => {
setChromeVisible(false);
}, 3000);
return () => {
window.clearTimeout(timeout);
};
}, [chromeVisible, immersive]);
const immersiveTapHandlers = useMemo<ImmersiveTapHandlers | undefined>(() => {
if (!immersive) {
return undefined;
}
return {
onPointerDown: event => {
tapStateRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
tappable: isImmersiveTapTarget(event.target),
};
},
onPointerUp: event => {
const tapState = tapStateRef.current;
tapStateRef.current = null;
if (
!tapState ||
tapState.pointerId !== event.pointerId ||
!tapState.tappable ||
!isTapWithinSlop(tapState, event)
) {
return;
}
setChromeVisible(visible => !visible);
},
onPointerCancel: () => {
tapStateRef.current = null;
},
};
}, [immersive]);
return (
<>
{(!immersive || chromeVisible) && (
<MobileDetailPageHeader
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
trackScrollTitle={trackScrollTitle}
/>
)}
<JournalConflictBlock date={date} />
<DetailPageImpl
immersive={immersive}
chromeVisible={chromeVisible}
immersiveTapHandlers={immersiveTapHandlers}
/>
<AppTabs
background={cssVarV2('layer/background/primary')}
hidden={immersive && !chromeVisible}
/>
</>
);
};
const MobileDetailPage = ({
pageId,
@@ -240,7 +538,6 @@ const MobileDetailPage = ({
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const [showTitle, setShowTitle] = useState(checkShowTitle);
const title = useLiveData(docDisplayMetaService.title$(pageId));
const canAccess = useGuard('Doc_Read', pageId);
@@ -265,11 +562,6 @@ const MobileDetailPage = ({
[fromTab, journalService, workbench]
);
useGlobalEvent(
'scroll',
useCallback(() => setShowTitle(checkShowTitle()), [])
);
return (
<div className={styles.root}>
<DetailPageWrapper
@@ -278,37 +570,15 @@ const MobileDetailPage = ({
pageId={pageId}
canAccess={canAccess}
>
<PageHeader
back={!fromTab}
className={styles.header}
contentClassName={styles.headerContent}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
bottom={
date ? (
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
) : null
}
bottomSpacer={94}
>
<span data-show={!!date || showTitle} className={styles.headerTitle}>
{date
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
: title}
</span>
</PageHeader>
<JournalConflictBlock date={date} />
<DetailPageImpl />
<AppTabs background={cssVarV2('layer/background/primary')} />
<MobileDetailPageContent
key={pageId}
pageId={pageId}
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
/>
</DetailPageWrapper>
</div>
);
@@ -7,6 +7,7 @@ globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
'--affine-edgeless-zoom-toolbar-bottom': `calc(10px + ${globalVars.appTabSafeArea})`,
},
userSelect: 'none',
WebkitUserSelect: 'none',
@@ -318,29 +318,71 @@ export class Editor extends Entity {
}
// update scroll position when scrollViewport scroll
const saveScrollPosition = () => {
if (this.mode$.value === 'page' && scrollViewport) {
this.scrollPosition.page = scrollViewport.scrollTop;
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
} else if (this.mode$.value === 'edgeless' && gfx) {
const pos = {
centerX: gfx.viewport.centerX,
centerY: gfx.viewport.centerY,
zoom: gfx.viewport.zoom,
};
this.scrollPosition.edgeless = pos;
this.workbenchView?.setScrollPosition(pos);
let edgelessWriteTimer: ReturnType<typeof setTimeout> | null = null;
const flushEdgelessScrollPosition = () => {
if (edgelessWriteTimer) {
clearTimeout(edgelessWriteTimer);
edgelessWriteTimer = null;
}
const pos = this.scrollPosition.edgeless;
if (!pos) {
return;
}
this.workbenchView?.setScrollPosition(pos);
};
scrollViewport?.addEventListener('scroll', saveScrollPosition);
const savePageScrollPosition = () => {
if (!scrollViewport || this.mode$.value !== 'page') {
return;
}
this.scrollPosition.page = scrollViewport.scrollTop;
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
};
const saveEdgelessScrollPosition = () => {
if (!gfx || this.mode$.value !== 'edgeless') {
return;
}
this.scrollPosition.edgeless = {
centerX: gfx.viewport.centerX,
centerY: gfx.viewport.centerY,
zoom: gfx.viewport.zoom,
};
if (edgelessWriteTimer) {
clearTimeout(edgelessWriteTimer);
}
edgelessWriteTimer = setTimeout(() => {
flushEdgelessScrollPosition();
}, 160);
};
const handleViewportScroll = () => {
if (this.mode$.value === 'edgeless' && scrollViewport) {
return;
}
savePageScrollPosition();
};
scrollViewport?.addEventListener('scroll', handleViewportScroll);
unsubs.push(() => {
scrollViewport?.removeEventListener('scroll', saveScrollPosition);
scrollViewport?.removeEventListener('scroll', handleViewportScroll);
});
if (gfx) {
const subscription =
gfx.viewport.viewportUpdated.subscribe(saveScrollPosition);
const subscription = gfx.viewport.viewportUpdated.subscribe(() => {
saveEdgelessScrollPosition();
});
unsubs.push(subscription.unsubscribe.bind(subscription));
}
unsubs.push(() => {
flushEdgelessScrollPosition();
});
// update selection when focusAt$ changed
const subscription = this.focusAt$
+1
View File
@@ -683,6 +683,7 @@ export const PackageList = [
location: 'blocksuite/affine/gfx/turbo-renderer',
name: '@blocksuite/affine-gfx-turbo-renderer',
workspaceDependencies: [
'blocksuite/affine/shared',
'blocksuite/framework/global',
'blocksuite/framework/std',
'blocksuite/framework/store',
+1
View File
@@ -2662,6 +2662,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@blocksuite/affine-gfx-turbo-renderer@workspace:blocksuite/affine/gfx/turbo-renderer"
dependencies:
"@blocksuite/affine-shared": "workspace:*"
"@blocksuite/global": "workspace:*"
"@blocksuite/std": "workspace:*"
"@blocksuite/store": "workspace:*"