diff --git a/.gitignore b/.gitignore index f33dd2ae9d..4741fee4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts new file mode 100644 index 0000000000..f907f9093b --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/canvas-budget.unit.spec.ts @@ -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; + } + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts new file mode 100644 index 0000000000..a34e37e127 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/placeholder-style.unit.spec.ts @@ -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); + }); +}); diff --git a/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts b/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts new file mode 100644 index 0000000000..328be8a208 --- /dev/null +++ b/blocksuite/affine/all/src/__tests__/gfx/turbo-renderer.unit.spec.ts @@ -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); + } + ); +}); diff --git a/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-block.ts b/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-block.ts index 630b8b6eae..e3b307bef9 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-block.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-block.ts @@ -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'], diff --git a/blocksuite/affine/blocks/root/src/preview/edgeless-root-preview-block.ts b/blocksuite/affine/blocks/root/src/preview/edgeless-root-preview-block.ts index 8ff071cb8b..af8168727c 100644 --- a/blocksuite/affine/blocks/root/src/preview/edgeless-root-preview-block.ts +++ b/blocksuite/affine/blocks/root/src/preview/edgeless-root-preview-block.ts @@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent { const blocks = this._gfx.grid.search( - this._gfx.viewport.viewportBounds, + this._gfx.viewport.overscanBlockBounds, { useSet: true, filter: ['block'], diff --git a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts index 04c8426255..ed92d7f559 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts @@ -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 +) { + 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 | 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 | 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`; diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts index c853eecacf..eac89de351 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts @@ -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 | 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, diff --git a/blocksuite/affine/blocks/surface/src/renderer/placeholder-style.ts b/blocksuite/affine/blocks/surface/src/renderer/placeholder-style.ts new file mode 100644 index 0000000000..7709a8b6a8 --- /dev/null +++ b/blocksuite/affine/blocks/surface/src/renderer/placeholder-style.ts @@ -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); +} diff --git a/blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts b/blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts index 6d6b1e205f..9a7a5baa5e 100644 --- a/blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts +++ b/blocksuite/affine/gfx/connector/src/renderer/element-renderer.ts @@ -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 } diff --git a/blocksuite/affine/gfx/turbo-renderer/package.json b/blocksuite/affine/gfx/turbo-renderer/package.json index 80a833089b..076652d51c 100644 --- a/blocksuite/affine/gfx/turbo-renderer/package.json +++ b/blocksuite/affine/gfx/turbo-renderer/package.json @@ -10,6 +10,7 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine-shared": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/std": "workspace:*", "@blocksuite/store": "workspace:*", diff --git a/blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts b/blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts index dbd7f39c12..242faf03d1 100644 --- a/blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts +++ b/blocksuite/affine/gfx/turbo-renderer/src/renderer-utils.ts @@ -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)); } }; diff --git a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts index fcba97786c..8e2757a523 100644 --- a/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts +++ b/blocksuite/affine/gfx/turbo-renderer/src/turbo-renderer.ts @@ -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 | 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(); } diff --git a/blocksuite/affine/gfx/turbo-renderer/tsconfig.json b/blocksuite/affine/gfx/turbo-renderer/tsconfig.json index d7d3a1f74b..67efd9910c 100644 --- a/blocksuite/affine/gfx/turbo-renderer/tsconfig.json +++ b/blocksuite/affine/gfx/turbo-renderer/tsconfig.json @@ -7,6 +7,7 @@ }, "include": ["./src"], "references": [ + { "path": "../../shared" }, { "path": "../../../framework/global" }, { "path": "../../../framework/std" }, { "path": "../../../framework/store" } diff --git a/blocksuite/affine/shared/src/theme/index.ts b/blocksuite/affine/shared/src/theme/index.ts index 48ddb6d474..40035a9080 100644 --- a/blocksuite/affine/shared/src/theme/index.ts +++ b/blocksuite/affine/shared/src/theme/index.ts @@ -1 +1,2 @@ export * from './css-variables.js'; +export * from './placeholder-style.js'; diff --git a/blocksuite/affine/shared/src/theme/placeholder-style.ts b/blocksuite/affine/shared/src/theme/placeholder-style.ts new file mode 100644 index 0000000000..844e15aba3 --- /dev/null +++ b/blocksuite/affine/shared/src/theme/placeholder-style.ts @@ -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)'; +} diff --git a/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/effects.ts b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/effects.ts index 23931066af..a8416ff795 100644 --- a/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/effects.ts +++ b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/effects.ts @@ -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 diff --git a/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/index.ts b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/index.ts index 4e5e72188a..3675a2564e 100644 --- a/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/index.ts +++ b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/index.ts @@ -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`; + } + return html` diff --git a/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/mobile-zoom-ruler.ts b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/mobile-zoom-ruler.ts new file mode 100644 index 0000000000..6f039b3af5 --- /dev/null +++ b/blocksuite/affine/widgets/edgeless-zoom-toolbar/src/mobile-zoom-ruler.ts @@ -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` +
+ ${formattedZoom} + + +
+ `; + } + + @property({ attribute: false }) + accessor std!: BlockStdScope; +} diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/README.md b/blocksuite/docs/api/@blocksuite/std/gfx/README.md index c8c4fd600a..d6d0fc66b3 100644 --- a/blocksuite/docs/api/@blocksuite/std/gfx/README.md +++ b/blocksuite/docs/api/@blocksuite/std/gfx/README.md @@ -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) diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/functions/getEffectiveDpr.md b/blocksuite/docs/api/@blocksuite/std/gfx/functions/getEffectiveDpr.md new file mode 100644 index 0000000000..99ee80bfc7 --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/std/gfx/functions/getEffectiveDpr.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` diff --git a/blocksuite/docs/api/@blocksuite/std/gfx/variables/viewportRuntimeConfig.md b/blocksuite/docs/api/@blocksuite/std/gfx/variables/viewportRuntimeConfig.md new file mode 100644 index 0000000000..f881907153 --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/std/gfx/variables/viewportRuntimeConfig.md @@ -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` diff --git a/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts b/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts index ad645edfd8..d8c7cfb28e 100644 --- a/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts +++ b/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts @@ -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(); + 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(); - 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; + } + )._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(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); diff --git a/blocksuite/framework/std/src/gfx/viewport-element.ts b/blocksuite/framework/std/src/gfx/viewport-element.ts index ad7e6775d8..f5e44922a3 100644 --- a/blocksuite/framework/std/src/gfx/viewport-element.ts +++ b/blocksuite/framework/std/src/gfx/viewport-element.ts @@ -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; + viewportModels: Set; + viewportBounds: Bound; + nearbyActiveBlockLimit: number; + nearbyDistanceRatio: number; +}): Set { + const activeModels = new Set(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 ; 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 + ) { + 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(); + 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(); + 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; + 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 | 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(); } diff --git a/blocksuite/framework/std/src/gfx/viewport.ts b/blocksuite/framework/std/src/gfx/viewport.ts index e4ab5acbd1..3845bafd5e 100644 --- a/blocksuite/framework/std/src/gfx/viewport.ts +++ b/blocksuite/framework/std/src/gfx/viewport.ts @@ -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(); viewportUpdated = new Subject<{ @@ -99,12 +220,71 @@ export class Viewport { center: IVec; }>(); + zoomUpdated = new Subject<{ + previousZoom: number; + zoom: number; + }>(); + zooming$ = new BehaviorSubject(false); panning$ = new BehaviorSubject(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(); }); diff --git a/blocksuite/framework/std/src/view/element/gfx-block-component.ts b/blocksuite/framework/std/src/view/element/gfx-block-component.ts index 17b6a1605b..189b1b55f5 100644 --- a/blocksuite/framework/std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/std/src/view/element/gfx-block-component.ts @@ -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( diff --git a/packages/frontend/apps/electron/src/main/shared-storage/json-file.ts b/packages/frontend/apps/electron/src/main/shared-storage/json-file.ts index c11e30ae47..e7dc1b12a6 100644 --- a/packages/frontend/apps/electron/src/main/shared-storage/json-file.ts +++ b/packages/frontend/apps/electron/src/main/shared-storage/json-file.ts @@ -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), diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 9497283ff7..9151d0357e 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; @@ -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)"; diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index 3ec69a5cc5..7ee5d3f33e 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -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() + } } diff --git a/packages/frontend/apps/ios/App/App/Info.plist b/packages/frontend/apps/ios/App/App/Info.plist index fc2eb6dbcf..4f7c2c1952 100644 --- a/packages/frontend/apps/ios/App/App/Info.plist +++ b/packages/frontend/apps/ios/App/App/Info.plist @@ -32,7 +32,7 @@ CFBundleVersion - 10 + 17 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS @@ -42,7 +42,7 @@ NSPhotoLibraryUsageDescription AFFiNE requires access to select photos from your photo library and insert them into your documents NSUserTrackingUsageDescription - 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. + 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. UILaunchScreen UIImageName @@ -69,10 +69,10 @@ UIViewControllerBasedStatusBarAppearance - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - + NSAppTransportSecurity + + NSAllowsLocalNetworking + + diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift index a74b877cec..7035fc3585 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/ChatManager/ChatManager+Stream.swift @@ -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) diff --git a/packages/frontend/apps/ios/App/Podfile.lock b/packages/frontend/apps/ios/App/Podfile.lock index 861cf00bf9..e7f8112cbb 100644 --- a/packages/frontend/apps/ios/App/Podfile.lock +++ b/packages/frontend/apps/ios/App/Podfile.lock @@ -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 diff --git a/packages/frontend/apps/ios/capacitor.config.ts b/packages/frontend/apps/ios/capacitor.config.ts index 8dda1ee4e0..c1e830e957 100644 --- a/packages/frontend/apps/ios/capacitor.config.ts +++ b/packages/frontend/apps/ios/capacitor.config.ts @@ -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', diff --git a/packages/frontend/apps/ios/src/setup.ts b/packages/frontend/apps/ios/src/setup.ts index 868df4f032..acb26773ec 100644 --- a/packages/frontend/apps/ios/src/setup.ts +++ b/packages/frontend/apps/ios/src/setup.ts @@ -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], +]; diff --git a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx index b13e4fb1c7..217f84d7b2 100644 --- a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx +++ b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx @@ -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', }} >
    diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.css.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.css.ts index 78d42de68e..b9af17b357 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.css.ts +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.spec.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.spec.ts new file mode 100644 index 0000000000..5e5c967e98 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.spec.ts @@ -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); + }); +}); diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.ts b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.ts new file mode 100644 index 0000000000..676be213cd --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.immersive.ts @@ -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'; +} diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index bf734357e7..1f948e38b3 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -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) => void; + onPointerUp: (event: ReactPointerEvent) => 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 (
    - {/* Add a key to force rerender when page changed, to avoid error boundary persisting. */} @@ -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; + 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 ( + + + + + } + bottom={ + date ? ( + + ) : null + } + bottomSpacer={94} + > + + {date + ? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } }) + : title} + + + ); +}; + +const MobileDetailPageContent = ({ + pageId, + date, + fromTab, + title, + allJournalDates, + handleDateChange, +}: { + pageId: string; + date?: string; + fromTab: boolean; + title?: string; + allJournalDates: Set; + 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(() => { + 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) && ( + + )} + + +