fix(editor): edgeless can't slider with finger (#15091)
fix bug edgeless can't slider with finger <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added mobile immersive edgeless mode with dynamic chrome auto-hide and tap-gesture controls. * Added a mobile zoom ruler UI for edgeless. * **Bug Fixes** * Improved iOS rendering/zoom by applying low-zoom survival behavior, gesture-aware refresh deferral, and effective-DPR canvas scaling. * Fixed iOS webview zoom/bounce and process-termination reload behavior. * Improved placeholder styling with theme-aware colors. * **Chores** * Updated local ignore rules and iOS app build/version configuration. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -6,6 +6,7 @@
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
.yarn/versions
|
.yarn/versions
|
||||||
|
.corepack-bin
|
||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
*dist
|
*dist
|
||||||
@@ -50,6 +51,7 @@ tsconfig.tsbuildinfo
|
|||||||
.context
|
.context
|
||||||
/*.md
|
/*.md
|
||||||
.codex
|
.codex
|
||||||
|
.cursor
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -94,3 +96,9 @@ af.cmd
|
|||||||
|
|
||||||
# playwright
|
# playwright
|
||||||
storageState.json
|
storageState.json
|
||||||
|
/.understand-anything
|
||||||
|
|
||||||
|
# local test/browser artifacts
|
||||||
|
/.playwright-browsers/
|
||||||
|
**/.vitest-attachments/
|
||||||
|
/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/
|
||||||
|
|||||||
@@ -0,0 +1,770 @@
|
|||||||
|
import { Bound } from '@blocksuite/global/gfx';
|
||||||
|
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
|
||||||
|
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
|
||||||
|
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
|
||||||
|
import {
|
||||||
|
paintPlaceholder,
|
||||||
|
syncCanvasSize,
|
||||||
|
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
|
||||||
|
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
|
||||||
|
|
||||||
|
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
|
||||||
|
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
|
||||||
|
window,
|
||||||
|
'devicePixelRatio'
|
||||||
|
);
|
||||||
|
|
||||||
|
function setDevicePixelRatio(value: number) {
|
||||||
|
Object.defineProperty(window, 'devicePixelRatio', {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRect(width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: width,
|
||||||
|
bottom: height,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeBlockModel(
|
||||||
|
id: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w = 10,
|
||||||
|
h = 10
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
elementBound: new Bound(x, y, w, h),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaintPlaceholderForTest = (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
layout: ViewportLayoutTree,
|
||||||
|
viewport: {
|
||||||
|
zoom: number;
|
||||||
|
toViewCoord: (x: number, y: number) => [number, number];
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
|
||||||
|
|
||||||
|
if (originalDevicePixelRatio) {
|
||||||
|
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edgeless canvas budget', () => {
|
||||||
|
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||||
|
[0.5, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const shouldSyncCanvasBudgetOnViewportUpdate = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||||
|
previousZoom: number,
|
||||||
|
nextZoom: number,
|
||||||
|
rawDpr?: number
|
||||||
|
) => boolean;
|
||||||
|
}
|
||||||
|
).shouldSyncCanvasBudgetOnViewportUpdate;
|
||||||
|
|
||||||
|
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
|
||||||
|
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
|
||||||
|
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
|
||||||
|
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enables low-zoom survival mode only for active iOS gestures', () => {
|
||||||
|
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
|
||||||
|
|
||||||
|
const shouldUseLowZoomSurvivalMode = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldUseLowZoomSurvivalMode: (
|
||||||
|
isIOS: boolean,
|
||||||
|
zoom: number,
|
||||||
|
gestureActive: boolean
|
||||||
|
) => boolean;
|
||||||
|
}
|
||||||
|
).shouldUseLowZoomSurvivalMode;
|
||||||
|
|
||||||
|
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
|
||||||
|
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
|
||||||
|
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
|
||||||
|
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
|
||||||
|
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
|
||||||
|
|
||||||
|
const shouldRenderCanvasPlaceholders = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldRenderCanvasPlaceholders: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
isPanning: boolean;
|
||||||
|
isZooming: boolean;
|
||||||
|
skipRefreshDuringGesture: boolean;
|
||||||
|
turboEnabled: boolean;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldRenderCanvasPlaceholders;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldRenderCanvasPlaceholders({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
isPanning: true,
|
||||||
|
isZooming: false,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
turboEnabled: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldRenderCanvasPlaceholders({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
isPanning: false,
|
||||||
|
isZooming: true,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
turboEnabled: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
|
||||||
|
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
|
||||||
|
expect(
|
||||||
|
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const getStackingCanvasBypassState = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
getStackingCanvasBypassState: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
gestureActive: boolean;
|
||||||
|
recoveryActive: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).getStackingCanvasBypassState;
|
||||||
|
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
gestureActive: boolean;
|
||||||
|
recoveryActive: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: true,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: false,
|
||||||
|
recoveryActive: true,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: false,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: false,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: true,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 430,
|
||||||
|
viewportHeight: 932,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.6,
|
||||||
|
gestureActive: true,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasBypassState({
|
||||||
|
isIOS: false,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: true,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
|
||||||
|
expect(
|
||||||
|
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
|
||||||
|
).toBe(true);
|
||||||
|
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
gestureActive: boolean;
|
||||||
|
recoveryActive: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldBypassStackingCanvasesDuringLowZoomGesture;
|
||||||
|
const getStackingCanvasAttachmentDiff = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
getStackingCanvasAttachmentDiff: (params: {
|
||||||
|
canvases: HTMLCanvasElement[];
|
||||||
|
wasAttached: boolean;
|
||||||
|
shouldAttach: boolean;
|
||||||
|
}) => {
|
||||||
|
added: HTMLCanvasElement[];
|
||||||
|
removed: HTMLCanvasElement[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).getStackingCanvasAttachmentDiff;
|
||||||
|
|
||||||
|
const canvases = [document.createElement('canvas')];
|
||||||
|
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
|
||||||
|
isIOS: true,
|
||||||
|
zoom: 0.4,
|
||||||
|
gestureActive: true,
|
||||||
|
recoveryActive: false,
|
||||||
|
viewportWidth: 932,
|
||||||
|
viewportHeight: 430,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldBypass).toBe(true);
|
||||||
|
expect(
|
||||||
|
getStackingCanvasAttachmentDiff({
|
||||||
|
canvases,
|
||||||
|
wasAttached: true,
|
||||||
|
shouldAttach: !shouldBypass,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
added: [],
|
||||||
|
removed: canvases,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses overscan for main-canvas fallback culling and render origin', () => {
|
||||||
|
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
|
||||||
|
|
||||||
|
const getMainCanvasFallbackBounds = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
getMainCanvasFallbackBounds: (params: {
|
||||||
|
viewportBounds: Bound;
|
||||||
|
overscanViewportBounds: Bound;
|
||||||
|
}) => {
|
||||||
|
cullBound: Bound;
|
||||||
|
renderBound: Bound;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).getMainCanvasFallbackBounds;
|
||||||
|
|
||||||
|
const viewportBounds = new Bound(100, 200, 300, 150);
|
||||||
|
const overscanViewportBounds = new Bound(40, 170, 420, 210);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getMainCanvasFallbackBounds({
|
||||||
|
viewportBounds,
|
||||||
|
overscanViewportBounds,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
cullBound: overscanViewportBounds,
|
||||||
|
renderBound: overscanViewportBounds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lays out overscan canvases relative to the exact viewport', () => {
|
||||||
|
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
|
||||||
|
|
||||||
|
const getCanvasViewportLayout = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
getCanvasViewportLayout: (params: {
|
||||||
|
bound: Bound;
|
||||||
|
viewportBounds: Bound;
|
||||||
|
zoom: number;
|
||||||
|
viewScale: number;
|
||||||
|
dpr: number;
|
||||||
|
}) => {
|
||||||
|
actualHeight: number;
|
||||||
|
actualWidth: number;
|
||||||
|
height: number;
|
||||||
|
transform: string;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).getCanvasViewportLayout;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getCanvasViewportLayout({
|
||||||
|
bound: new Bound(40, 170, 420, 210),
|
||||||
|
viewportBounds: new Bound(100, 200, 300, 150),
|
||||||
|
zoom: 1,
|
||||||
|
viewScale: 1,
|
||||||
|
dpr: 2,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
actualHeight: 420,
|
||||||
|
actualWidth: 840,
|
||||||
|
height: 210,
|
||||||
|
transform: 'translate(-60px, -30px) scale(1)',
|
||||||
|
width: 420,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
|
||||||
|
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStackingCanvasAttachmentDiff = (
|
||||||
|
canvasRendererModule as {
|
||||||
|
getStackingCanvasAttachmentDiff: (params: {
|
||||||
|
canvases: HTMLCanvasElement[];
|
||||||
|
wasAttached: boolean;
|
||||||
|
shouldAttach: boolean;
|
||||||
|
}) => {
|
||||||
|
added: HTMLCanvasElement[];
|
||||||
|
removed: HTMLCanvasElement[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).getStackingCanvasAttachmentDiff;
|
||||||
|
|
||||||
|
const canvasA = document.createElement('canvas');
|
||||||
|
const canvasB = document.createElement('canvas');
|
||||||
|
const canvases = [canvasA, canvasB];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getStackingCanvasAttachmentDiff({
|
||||||
|
canvases,
|
||||||
|
wasAttached: true,
|
||||||
|
shouldAttach: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
added: [],
|
||||||
|
removed: canvases,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getStackingCanvasAttachmentDiff({
|
||||||
|
canvases,
|
||||||
|
wasAttached: false,
|
||||||
|
shouldAttach: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
added: canvases,
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getStackingCanvasAttachmentDiff({
|
||||||
|
canvases,
|
||||||
|
wasAttached: true,
|
||||||
|
shouldAttach: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||||
|
[0.5, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
const viewport = new Viewport();
|
||||||
|
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
|
||||||
|
const viewportUpdated = vi.fn();
|
||||||
|
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
|
||||||
|
let lastCanvasBudgetZoom = viewport.zoom;
|
||||||
|
let budgetSyncCount = 0;
|
||||||
|
|
||||||
|
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||||
|
|
||||||
|
expect('zoomUpdated' in viewport).toBe(true);
|
||||||
|
const zoomUpdated = (
|
||||||
|
viewport as unknown as {
|
||||||
|
zoomUpdated: {
|
||||||
|
subscribe: (
|
||||||
|
callback: (update: { previousZoom: number; zoom: number }) => void
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).zoomUpdated;
|
||||||
|
|
||||||
|
zoomUpdated.subscribe(update => {
|
||||||
|
zoomUpdates.push(update);
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
canvasRendererModule as {
|
||||||
|
shouldSyncCanvasBudgetOnViewportUpdate: (
|
||||||
|
previousZoom: number,
|
||||||
|
nextZoom: number,
|
||||||
|
rawDpr?: number
|
||||||
|
) => boolean;
|
||||||
|
}
|
||||||
|
).shouldSyncCanvasBudgetOnViewportUpdate(
|
||||||
|
lastCanvasBudgetZoom,
|
||||||
|
update.zoom,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
budgetSyncCount += 1;
|
||||||
|
}
|
||||||
|
lastCanvasBudgetZoom = update.zoom;
|
||||||
|
});
|
||||||
|
|
||||||
|
viewport.panning$.next(true);
|
||||||
|
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
|
||||||
|
|
||||||
|
expect(viewportUpdated).not.toHaveBeenCalled();
|
||||||
|
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
|
||||||
|
expect(budgetSyncCount).toBe(1);
|
||||||
|
|
||||||
|
viewport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
|
||||||
|
const viewport = new Viewport();
|
||||||
|
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
|
||||||
|
const viewportUpdated = vi.fn();
|
||||||
|
const zoomUpdated = vi.fn();
|
||||||
|
|
||||||
|
viewport.viewportUpdated.subscribe(viewportUpdated);
|
||||||
|
viewport.zoomUpdated.subscribe(zoomUpdated);
|
||||||
|
|
||||||
|
viewport.setZoom(0.4, { x: 0, y: 0 });
|
||||||
|
|
||||||
|
expect(viewportUpdated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
|
||||||
|
expect(viewport.panning$.value).toBe(false);
|
||||||
|
expect(viewport.zooming$.value).toBe(false);
|
||||||
|
|
||||||
|
viewport.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enables low-zoom block survival only while the gesture is still active', () => {
|
||||||
|
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldUseLowZoomBlockSurvivalMode = (
|
||||||
|
viewportElementModule as {
|
||||||
|
shouldUseLowZoomBlockSurvivalMode: (params: {
|
||||||
|
zoom: number;
|
||||||
|
skipRefreshDuringGesture: boolean;
|
||||||
|
gestureActive: boolean;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldUseLowZoomBlockSurvivalMode;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.4,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
gestureActive: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.4,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
gestureActive: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
|
||||||
|
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||||
|
|
||||||
|
const getLowZoomGestureActiveModels = (
|
||||||
|
viewportElementModule as {
|
||||||
|
getLowZoomGestureActiveModels: (params: {
|
||||||
|
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||||
|
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||||
|
viewportBounds: Bound;
|
||||||
|
nearbyActiveBlockLimit: number;
|
||||||
|
nearbyDistanceRatio: number;
|
||||||
|
}) => Set<{ id: string; elementBound: Bound }>;
|
||||||
|
}
|
||||||
|
).getLowZoomGestureActiveModels;
|
||||||
|
|
||||||
|
const selected = createFakeBlockModel('selected', 10, 10);
|
||||||
|
const nearby = createFakeBlockModel('nearby', 28, 12);
|
||||||
|
const far = createFakeBlockModel('far', 78, 78);
|
||||||
|
|
||||||
|
const activeModels = getLowZoomGestureActiveModels({
|
||||||
|
selectedModels: new Set([selected]),
|
||||||
|
viewportModels: new Set([selected, nearby, far]),
|
||||||
|
viewportBounds: new Bound(0, 0, 100, 100),
|
||||||
|
nearbyActiveBlockLimit: 1,
|
||||||
|
nearbyDistanceRatio: 0.35,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...activeModels].map(model => model.id).sort()).toEqual([
|
||||||
|
'nearby',
|
||||||
|
'selected',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the nearest viewport block when nothing is selected', () => {
|
||||||
|
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
|
||||||
|
|
||||||
|
const getLowZoomGestureActiveModels = (
|
||||||
|
viewportElementModule as {
|
||||||
|
getLowZoomGestureActiveModels: (params: {
|
||||||
|
selectedModels: Set<{ id: string; elementBound: Bound }>;
|
||||||
|
viewportModels: Set<{ id: string; elementBound: Bound }>;
|
||||||
|
viewportBounds: Bound;
|
||||||
|
nearbyActiveBlockLimit: number;
|
||||||
|
nearbyDistanceRatio: number;
|
||||||
|
}) => Set<{ id: string; elementBound: Bound }>;
|
||||||
|
}
|
||||||
|
).getLowZoomGestureActiveModels;
|
||||||
|
|
||||||
|
const nearest = createFakeBlockModel('nearest', 46, 46);
|
||||||
|
const farther = createFakeBlockModel('farther', 78, 78);
|
||||||
|
|
||||||
|
const activeModels = getLowZoomGestureActiveModels({
|
||||||
|
selectedModels: new Set(),
|
||||||
|
viewportModels: new Set([nearest, farther]),
|
||||||
|
viewportBounds: new Bound(0, 0, 100, 100),
|
||||||
|
nearbyActiveBlockLimit: 1,
|
||||||
|
nearbyDistanceRatio: 0.35,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
|
||||||
|
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||||
|
|
||||||
|
const getPostGestureRecoveryDelay = (
|
||||||
|
viewportModule as {
|
||||||
|
getPostGestureRecoveryDelay: (params: {
|
||||||
|
isPanning: boolean;
|
||||||
|
isZooming: boolean;
|
||||||
|
fallbackDelayMs: number;
|
||||||
|
}) => number;
|
||||||
|
}
|
||||||
|
).getPostGestureRecoveryDelay;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getPostGestureRecoveryDelay({
|
||||||
|
isPanning: false,
|
||||||
|
isZooming: false,
|
||||||
|
fallbackDelayMs: 220,
|
||||||
|
})
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
|
||||||
|
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
|
||||||
|
|
||||||
|
const getPostGestureRecoveryDelay = (
|
||||||
|
viewportModule as {
|
||||||
|
getPostGestureRecoveryDelay: (params: {
|
||||||
|
isPanning: boolean;
|
||||||
|
isZooming: boolean;
|
||||||
|
fallbackDelayMs: number;
|
||||||
|
}) => number;
|
||||||
|
}
|
||||||
|
).getPostGestureRecoveryDelay;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getPostGestureRecoveryDelay({
|
||||||
|
isPanning: true,
|
||||||
|
isZooming: false,
|
||||||
|
fallbackDelayMs: 220,
|
||||||
|
})
|
||||||
|
).toBe(220);
|
||||||
|
expect(
|
||||||
|
getPostGestureRecoveryDelay({
|
||||||
|
isPanning: false,
|
||||||
|
isZooming: true,
|
||||||
|
fallbackDelayMs: 220,
|
||||||
|
})
|
||||||
|
).toBe(220);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||||
|
[0.5, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
];
|
||||||
|
setDevicePixelRatio(2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const host = document.createElement('div');
|
||||||
|
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
|
||||||
|
createRect(200, 100)
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
syncCanvasSize as unknown as (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
host: HTMLElement,
|
||||||
|
zoom: number
|
||||||
|
) => void
|
||||||
|
)(canvas, host, 0.4);
|
||||||
|
|
||||||
|
expect(canvas.width).toBe(200);
|
||||||
|
expect(canvas.height).toBe(100);
|
||||||
|
|
||||||
|
(
|
||||||
|
syncCanvasSize as unknown as (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
host: HTMLElement,
|
||||||
|
zoom: number
|
||||||
|
) => void
|
||||||
|
)(canvas, host, 0.95);
|
||||||
|
|
||||||
|
expect(canvas.width).toBe(400);
|
||||||
|
expect(canvas.height).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('paints turbo placeholders with effective dpr at low zoom', () => {
|
||||||
|
const previousTheme = document.documentElement.dataset.theme;
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
|
|
||||||
|
try {
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||||
|
[0.5, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
];
|
||||||
|
setDevicePixelRatio(2);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const fillRect = vi.fn();
|
||||||
|
const strokeRect = vi.fn();
|
||||||
|
let fillStyle = '';
|
||||||
|
let strokeStyle = '';
|
||||||
|
vi.spyOn(canvas, 'getContext').mockReturnValue({
|
||||||
|
get fillStyle() {
|
||||||
|
return fillStyle;
|
||||||
|
},
|
||||||
|
set fillStyle(value: string) {
|
||||||
|
fillStyle = value;
|
||||||
|
},
|
||||||
|
get strokeStyle() {
|
||||||
|
return strokeStyle;
|
||||||
|
},
|
||||||
|
set strokeStyle(value: string) {
|
||||||
|
strokeStyle = value;
|
||||||
|
},
|
||||||
|
fillRect,
|
||||||
|
strokeRect,
|
||||||
|
} as unknown as CanvasRenderingContext2D);
|
||||||
|
|
||||||
|
const layout: ViewportLayoutTree = {
|
||||||
|
roots: [
|
||||||
|
{
|
||||||
|
blockId: 'root',
|
||||||
|
type: 'affine:page',
|
||||||
|
layout: {
|
||||||
|
blockId: 'root',
|
||||||
|
type: 'affine:page',
|
||||||
|
rect: { x: 0, y: 0, w: 50, h: 20 },
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
overallRect: { x: 0, y: 0, w: 50, h: 20 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const paintPlaceholderForTest =
|
||||||
|
paintPlaceholder as unknown as PaintPlaceholderForTest;
|
||||||
|
|
||||||
|
paintPlaceholderForTest(canvas, layout, {
|
||||||
|
zoom: 0.4,
|
||||||
|
toViewCoord: () => [0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
|
||||||
|
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
|
||||||
|
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
|
||||||
|
|
||||||
|
paintPlaceholderForTest(canvas, layout, {
|
||||||
|
zoom: 0.95,
|
||||||
|
toViewCoord: () => [0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
|
||||||
|
} finally {
|
||||||
|
document.documentElement.dataset.theme = previousTheme;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ColorScheme } from '@blocksuite/affine-model';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAffinePlaceholderFillColor,
|
||||||
|
getAffinePlaceholderStrokeColor,
|
||||||
|
inferColorSchemeFromThemeMode,
|
||||||
|
} from '../../../../shared/src/theme/placeholder-style.js';
|
||||||
|
|
||||||
|
describe('affine placeholder style', () => {
|
||||||
|
it('returns subtle light placeholder colors', () => {
|
||||||
|
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
|
||||||
|
'rgba(0, 0, 0, 0.04)'
|
||||||
|
);
|
||||||
|
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
|
||||||
|
'rgba(0, 0, 0, 0.02)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns subtle dark placeholder colors', () => {
|
||||||
|
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
|
||||||
|
'rgba(255, 255, 255, 0.08)'
|
||||||
|
);
|
||||||
|
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
|
||||||
|
'rgba(255, 255, 255, 0.04)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers color scheme from theme mode', () => {
|
||||||
|
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
|
||||||
|
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
|
||||||
|
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
|
||||||
|
|
||||||
|
describe('viewport turbo renderer policy', () => {
|
||||||
|
test.each([
|
||||||
|
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
|
||||||
|
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
|
||||||
|
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
|
||||||
|
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
|
||||||
|
])(
|
||||||
|
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
|
||||||
|
({ isIOS, zoom, hasBitmap, expected }) => {
|
||||||
|
expect(
|
||||||
|
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const shouldPreferBitmapCacheDuringLowZoomGesture = (
|
||||||
|
turboRendererModule as {
|
||||||
|
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
hasBitmap: boolean;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldPreferBitmapCacheDuringLowZoomGesture;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldPreferBitmapCacheDuringLowZoomGesture({
|
||||||
|
isIOS,
|
||||||
|
zoom,
|
||||||
|
hasBitmap,
|
||||||
|
})
|
||||||
|
).toBe(expected);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ isIOS: true, zoom: 0.4, expected: false },
|
||||||
|
{ isIOS: true, zoom: 0.8, expected: true },
|
||||||
|
{ isIOS: false, zoom: 0.4, expected: true },
|
||||||
|
])(
|
||||||
|
'idles turbo blocks outside iOS low-zoom survival mode %#',
|
||||||
|
({ isIOS, zoom, expected }) => {
|
||||||
|
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldIdleTurboBlocksDuringZooming = (
|
||||||
|
turboRendererModule as {
|
||||||
|
shouldIdleTurboBlocksDuringZooming: (params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
}) => boolean;
|
||||||
|
}
|
||||||
|
).shouldIdleTurboBlocksDuringZooming;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldIdleTurboBlocksDuringZooming({
|
||||||
|
isIOS,
|
||||||
|
zoom,
|
||||||
|
})
|
||||||
|
).toBe(expected);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -212,7 +212,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
|||||||
currentCenter.y
|
currentCenter.y
|
||||||
);
|
);
|
||||||
|
|
||||||
viewport.setZoom(zoom, new Point(baseX, baseY));
|
viewport.setZoom(zoom, new Point(baseX, baseY), false, true, true);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
@@ -351,7 +351,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom);
|
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();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
// pan
|
// pan
|
||||||
@@ -484,7 +484,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
|
|||||||
.viewport=${this.gfx.viewport}
|
.viewport=${this.gfx.viewport}
|
||||||
.getModelsInViewport=${() => {
|
.getModelsInViewport=${() => {
|
||||||
const blocks = this.gfx.grid.search(
|
const blocks = this.gfx.grid.search(
|
||||||
this.gfx.viewport.viewportBounds,
|
this.gfx.viewport.overscanBlockBounds,
|
||||||
{
|
{
|
||||||
useSet: true,
|
useSet: true,
|
||||||
filter: ['block'],
|
filter: ['block'],
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export class EdgelessRootPreviewBlockComponent extends BlockComponent<RootBlockM
|
|||||||
.viewport=${this._gfx.viewport}
|
.viewport=${this._gfx.viewport}
|
||||||
.getModelsInViewport=${() => {
|
.getModelsInViewport=${() => {
|
||||||
const blocks = this._gfx.grid.search(
|
const blocks = this._gfx.grid.search(
|
||||||
this._gfx.viewport.viewportBounds,
|
this._gfx.viewport.overscanBlockBounds,
|
||||||
{
|
{
|
||||||
useSet: true,
|
useSet: true,
|
||||||
filter: ['block'],
|
filter: ['block'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
|
|||||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||||
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
|
||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
|
import { IS_IOS } from '@blocksuite/global/env';
|
||||||
import {
|
import {
|
||||||
Bound,
|
Bound,
|
||||||
getBoundWithRotation,
|
getBoundWithRotation,
|
||||||
@@ -18,7 +19,12 @@ import type {
|
|||||||
SurfaceBlockModel,
|
SurfaceBlockModel,
|
||||||
Viewport,
|
Viewport,
|
||||||
} from '@blocksuite/std/gfx';
|
} 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 { effect } from '@preact/signals-core';
|
||||||
import last from 'lodash-es/last';
|
import last from 'lodash-es/last';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@@ -28,6 +34,7 @@ import { ElementRendererIdentifier } from '../extensions/element-renderer.js';
|
|||||||
import { RoughCanvas } from '../utils/rough/canvas.js';
|
import { RoughCanvas } from '../utils/rough/canvas.js';
|
||||||
import type { ElementRenderer } from './elements/index.js';
|
import type { ElementRenderer } from './elements/index.js';
|
||||||
import type { Overlay } from './overlay.js';
|
import type { Overlay } from './overlay.js';
|
||||||
|
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||||
|
|
||||||
type EnvProvider = {
|
type EnvProvider = {
|
||||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||||
@@ -116,6 +123,181 @@ type RefreshTarget =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STACKING_CANVAS_PADDING = 32;
|
const STACKING_CANVAS_PADDING = 32;
|
||||||
|
const IOS_LOW_ZOOM_SURVIVAL_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
export function shouldSyncCanvasBudgetOnViewportUpdate(
|
||||||
|
previousZoom: number,
|
||||||
|
nextZoom: number,
|
||||||
|
rawDpr = window.devicePixelRatio
|
||||||
|
) {
|
||||||
|
if (rawDpr <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
getEffectiveDpr(previousZoom, rawDpr) !== getEffectiveDpr(nextZoom, rawDpr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldUseLowZoomSurvivalMode(
|
||||||
|
isIOS: boolean,
|
||||||
|
zoom: number,
|
||||||
|
gestureActive: boolean
|
||||||
|
) {
|
||||||
|
return isIOS && gestureActive && zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStackingCanvasBypassState(params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
gestureActive: boolean;
|
||||||
|
recoveryActive: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isIOS,
|
||||||
|
zoom,
|
||||||
|
gestureActive,
|
||||||
|
recoveryActive,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
isIOS &&
|
||||||
|
zoom <= IOS_LOW_ZOOM_SURVIVAL_THRESHOLD &&
|
||||||
|
(gestureActive || recoveryActive) &&
|
||||||
|
viewportWidth > viewportHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldBypassStackingCanvasesDuringLowZoomGesture(params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
gestureActive: boolean;
|
||||||
|
recoveryActive: boolean;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
}) {
|
||||||
|
return getStackingCanvasBypassState(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStackingCanvasAttachmentDiff(params: {
|
||||||
|
canvases: HTMLCanvasElement[];
|
||||||
|
wasAttached: boolean;
|
||||||
|
shouldAttach: boolean;
|
||||||
|
}) {
|
||||||
|
const { canvases, wasAttached, shouldAttach } = params;
|
||||||
|
|
||||||
|
if (wasAttached === shouldAttach) {
|
||||||
|
return {
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldAttach
|
||||||
|
? {
|
||||||
|
added: canvases,
|
||||||
|
removed: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
added: [],
|
||||||
|
removed: canvases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainCanvasFallbackBounds(params: {
|
||||||
|
viewportBounds: Bound;
|
||||||
|
overscanViewportBounds: Bound;
|
||||||
|
}) {
|
||||||
|
const { overscanViewportBounds } = params;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cullBound: overscanViewportBounds,
|
||||||
|
renderBound: overscanViewportBounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCanvasViewportLayout(params: {
|
||||||
|
bound: Bound;
|
||||||
|
viewportBounds: Bound;
|
||||||
|
zoom: number;
|
||||||
|
viewScale: number;
|
||||||
|
dpr: number;
|
||||||
|
}) {
|
||||||
|
const { bound, viewportBounds, zoom, viewScale, dpr } = params;
|
||||||
|
const width = bound.w * zoom;
|
||||||
|
const height = bound.h * zoom;
|
||||||
|
const left = (bound.x - viewportBounds.x) * zoom;
|
||||||
|
const top = (bound.y - viewportBounds.y) * zoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualHeight: Math.max(0, Math.ceil(height * dpr)),
|
||||||
|
actualWidth: Math.max(0, Math.ceil(width * dpr)),
|
||||||
|
height,
|
||||||
|
transform: `translate(${left}px, ${top}px) scale(${1 / viewScale})`,
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCanvasViewportLayout(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
layout: ReturnType<typeof getCanvasViewportLayout>
|
||||||
|
) {
|
||||||
|
const width = `${layout.width}px`;
|
||||||
|
const height = `${layout.height}px`;
|
||||||
|
|
||||||
|
if (canvas.style.left !== '0px') {
|
||||||
|
canvas.style.left = '0px';
|
||||||
|
}
|
||||||
|
if (canvas.style.top !== '0px') {
|
||||||
|
canvas.style.top = '0px';
|
||||||
|
}
|
||||||
|
if (canvas.style.width !== width) {
|
||||||
|
canvas.style.width = width;
|
||||||
|
}
|
||||||
|
if (canvas.style.height !== height) {
|
||||||
|
canvas.style.height = height;
|
||||||
|
}
|
||||||
|
if (canvas.style.transform !== layout.transform) {
|
||||||
|
canvas.style.transform = layout.transform;
|
||||||
|
}
|
||||||
|
if (canvas.style.transformOrigin !== 'top left') {
|
||||||
|
canvas.style.transformOrigin = 'top left';
|
||||||
|
}
|
||||||
|
if (canvas.width !== layout.actualWidth) {
|
||||||
|
canvas.width = layout.actualWidth;
|
||||||
|
}
|
||||||
|
if (canvas.height !== layout.actualHeight) {
|
||||||
|
canvas.height = layout.actualHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRenderCanvasPlaceholders(params: {
|
||||||
|
isIOS: boolean;
|
||||||
|
zoom: number;
|
||||||
|
isPanning: boolean;
|
||||||
|
isZooming: boolean;
|
||||||
|
skipRefreshDuringGesture: boolean;
|
||||||
|
turboEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isIOS,
|
||||||
|
zoom,
|
||||||
|
isPanning,
|
||||||
|
isZooming,
|
||||||
|
skipRefreshDuringGesture,
|
||||||
|
turboEnabled,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
if (shouldUseLowZoomSurvivalMode(isIOS, zoom, isZooming)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !skipRefreshDuringGesture && turboEnabled && isZooming && !isPanning;
|
||||||
|
}
|
||||||
|
|
||||||
export class CanvasRenderer {
|
export class CanvasRenderer {
|
||||||
private _container!: HTMLElement;
|
private _container!: HTMLElement;
|
||||||
@@ -145,6 +327,19 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
private _needsFullRender = true;
|
private _needsFullRender = true;
|
||||||
|
|
||||||
|
private _lastCanvasBudgetZoom = 1;
|
||||||
|
|
||||||
|
private _lastLowZoomSurvivalMode = false;
|
||||||
|
|
||||||
|
private _lastBypassStackingCanvases = false;
|
||||||
|
|
||||||
|
private _stackingCanvasesAttached = true;
|
||||||
|
|
||||||
|
private _stackingCanvasRecoveryUntil = 0;
|
||||||
|
|
||||||
|
private _stackingCanvasRecoveryTimerId: ReturnType<typeof setTimeout> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
|
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
|
||||||
refreshCount: 0,
|
refreshCount: 0,
|
||||||
coalescedRefreshCount: 0,
|
coalescedRefreshCount: 0,
|
||||||
@@ -189,6 +384,10 @@ export class CanvasRenderer {
|
|||||||
return this._stackingCanvas;
|
return this._stackingCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get stackingCanvasesAttached() {
|
||||||
|
return this._stackingCanvasesAttached;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(options: RendererOptions) {
|
constructor(options: RendererOptions) {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
@@ -196,6 +395,7 @@ export class CanvasRenderer {
|
|||||||
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
|
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||||
this.std = options.std;
|
this.std = options.std;
|
||||||
this.viewport = options.viewport;
|
this.viewport = options.viewport;
|
||||||
|
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||||
this.layerManager = options.layerManager;
|
this.layerManager = options.layerManager;
|
||||||
this.grid = options.gridManager;
|
this.grid = options.gridManager;
|
||||||
this.provider = options.provider ?? {};
|
this.provider = options.provider ?? {};
|
||||||
@@ -223,22 +423,28 @@ export class CanvasRenderer {
|
|||||||
*
|
*
|
||||||
* It is not recommended to set width and height to 100%.
|
* It is not recommended to set width and height to 100%.
|
||||||
*/
|
*/
|
||||||
private _canvasSizeUpdater(dpr = window.devicePixelRatio) {
|
private _canvasSizeUpdater(
|
||||||
const { width, height, viewScale } = this.viewport;
|
bound = this.viewport.overscanViewportBounds,
|
||||||
const actualWidth = Math.ceil(width * dpr);
|
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||||
const actualHeight = Math.ceil(height * dpr);
|
) {
|
||||||
|
const layout = getCanvasViewportLayout({
|
||||||
|
bound,
|
||||||
|
viewportBounds: this.viewport.viewportBounds,
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
viewScale: this.viewport.viewScale,
|
||||||
|
dpr,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filter({ width, height }: HTMLCanvasElement) {
|
filter(canvas: HTMLCanvasElement) {
|
||||||
return width !== actualWidth || height !== actualHeight;
|
return (
|
||||||
|
canvas.width !== layout.actualWidth ||
|
||||||
|
canvas.height !== layout.actualHeight ||
|
||||||
|
canvas.style.transform !== layout.transform
|
||||||
|
);
|
||||||
},
|
},
|
||||||
update(canvas: HTMLCanvasElement) {
|
update(canvas: HTMLCanvasElement) {
|
||||||
canvas.style.width = `${width}px`;
|
applyCanvasViewportLayout(canvas, layout);
|
||||||
canvas.style.height = `${height}px`;
|
|
||||||
canvas.style.transform = `scale(${1 / viewScale})`;
|
|
||||||
canvas.style.transformOrigin = `top left`;
|
|
||||||
canvas.width = actualWidth;
|
|
||||||
canvas.height = actualHeight;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -246,7 +452,7 @@ export class CanvasRenderer {
|
|||||||
private _applyStackingCanvasLayout(
|
private _applyStackingCanvasLayout(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
bound: Bound | null,
|
bound: Bound | null,
|
||||||
dpr = window.devicePixelRatio
|
dpr = getEffectiveDpr(this.viewport.zoom)
|
||||||
) {
|
) {
|
||||||
const state =
|
const state =
|
||||||
this._stackingCanvasState.get(canvas) ??
|
this._stackingCanvasState.get(canvas) ??
|
||||||
@@ -270,44 +476,18 @@ export class CanvasRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { viewportBounds, zoom, viewScale } = this.viewport;
|
const layout = getCanvasViewportLayout({
|
||||||
const width = bound.w * zoom;
|
bound,
|
||||||
const height = bound.h * zoom;
|
viewportBounds: this.viewport.viewportBounds,
|
||||||
const left = (bound.x - viewportBounds.x) * zoom;
|
zoom: this.viewport.zoom,
|
||||||
const top = (bound.y - viewportBounds.y) * zoom;
|
viewScale: this.viewport.viewScale,
|
||||||
const actualWidth = Math.max(1, Math.ceil(width * dpr));
|
dpr,
|
||||||
const actualHeight = Math.max(1, Math.ceil(height * dpr));
|
});
|
||||||
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
|
|
||||||
|
|
||||||
if (canvas.style.display !== 'block') {
|
if (canvas.style.display !== 'block') {
|
||||||
canvas.style.display = 'block';
|
canvas.style.display = 'block';
|
||||||
}
|
}
|
||||||
if (canvas.style.left !== '0px') {
|
applyCanvasViewportLayout(canvas, layout);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.bound = bound;
|
state.bound = bound;
|
||||||
state.layerId = canvas.dataset.layerId ?? null;
|
state.layerId = canvas.dataset.layerId ?? null;
|
||||||
@@ -434,6 +614,125 @@ export class CanvasRenderer {
|
|||||||
this._applyStackingCanvasLayout(canvas, null);
|
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) {
|
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
|
||||||
const layer = this.layerManager;
|
const layer = this.layerManager;
|
||||||
const updateStackingCanvas = () => {
|
const updateStackingCanvas = () => {
|
||||||
@@ -476,7 +775,9 @@ export class CanvasRenderer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (diff > 0) {
|
if (diff > 0) {
|
||||||
|
if (this._stackingCanvasesAttached) {
|
||||||
payload.added = canvases.slice(-diff);
|
payload.added = canvases.slice(-diff);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
payload.removed = currentCanvases.slice(diff);
|
payload.removed = currentCanvases.slice(diff);
|
||||||
payload.removed.forEach(canvas => {
|
payload.removed.forEach(canvas => {
|
||||||
@@ -485,8 +786,10 @@ export class CanvasRenderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.added.length || payload.removed.length) {
|
||||||
this.stackingCanvasUpdated.next(payload);
|
this.stackingCanvasUpdated.next(payload);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.refresh({ type: 'all' });
|
this.refresh({ type: 'all' });
|
||||||
};
|
};
|
||||||
@@ -503,33 +806,119 @@ export class CanvasRenderer {
|
|||||||
private _initViewport() {
|
private _initViewport() {
|
||||||
let sizeUpdatedRafId: number | null = null;
|
let sizeUpdatedRafId: number | null = null;
|
||||||
|
|
||||||
|
this._disposables.add({
|
||||||
|
dispose: () => this._clearStackingCanvasRecoveryTimer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._disposables.add(
|
||||||
|
this.viewport.zoomUpdated.subscribe(() => {
|
||||||
|
this._syncCanvasBudgetForViewportZoom();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => {
|
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.refresh({ type: 'all' });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.sizeUpdated.subscribe(() => {
|
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;
|
if (sizeUpdatedRafId) return;
|
||||||
sizeUpdatedRafId = requestConnectedFrame(() => {
|
sizeUpdatedRafId = requestConnectedFrame(() => {
|
||||||
sizeUpdatedRafId = null;
|
sizeUpdatedRafId = null;
|
||||||
this._resetSize();
|
this._resetSize();
|
||||||
|
// 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._render();
|
||||||
|
}
|
||||||
}, this._container);
|
}, this._container);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.zooming$.subscribe(isZooming => {
|
this.viewport.zooming$.subscribe(() => {
|
||||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
this._updatePlaceholderMode();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
// When SKIP_REFRESH_DURING_GESTURE is enabled, defer heavy canvas work
|
||||||
this.usePlaceholder = shouldRenderPlaceholders;
|
// while the gesture is still in-flight, but start the first recovery frame
|
||||||
|
// immediately once both gesture signals have fully settled.
|
||||||
|
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
|
let pendingCanvasTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const cancelPendingCanvasRefresh = () => {
|
||||||
|
if (pendingCanvasTimerId !== null) {
|
||||||
|
clearTimeout(pendingCanvasTimerId);
|
||||||
|
pendingCanvasTimerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleCanvasRefresh = () => {
|
||||||
|
cancelPendingCanvasRefresh();
|
||||||
|
const delayMs = getPostGestureRecoveryDelay({
|
||||||
|
isPanning: this.viewport.panning$.value,
|
||||||
|
isZooming: this.viewport.zooming$.value,
|
||||||
|
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
|
||||||
|
});
|
||||||
|
pendingCanvasTimerId = setTimeout(() => {
|
||||||
|
pendingCanvasTimerId = null;
|
||||||
|
// If a gesture is still in-flight when the timer fires, reschedule
|
||||||
|
// instead of dropping. Dropping here left connectors blank until a
|
||||||
|
// tap forced a synchronous refresh.
|
||||||
|
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||||
|
scheduleCanvasRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.refresh({ type: 'all' });
|
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;
|
let wasDragging = false;
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
@@ -537,8 +926,12 @@ export class CanvasRenderer {
|
|||||||
const isDragging = this._gfx.tool.dragging$.value;
|
const isDragging = this._gfx.tool.dragging$.value;
|
||||||
|
|
||||||
if (wasDragging && !isDragging) {
|
if (wasDragging && !isDragging) {
|
||||||
|
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||||
|
// Deferred refresh will handle it after gesture ends
|
||||||
|
} else {
|
||||||
this.refresh({ type: 'all' });
|
this.refresh({ type: 'all' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wasDragging = isDragging;
|
wasDragging = isDragging;
|
||||||
})
|
})
|
||||||
@@ -572,14 +965,32 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
private _render() {
|
private _render() {
|
||||||
const renderStart = performance.now();
|
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 { ctx } = this;
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = getEffectiveDpr(zoom);
|
||||||
const scale = zoom * dpr;
|
const scale = zoom * dpr;
|
||||||
const matrix = new DOMMatrix().scaleSelf(scale);
|
const matrix = new DOMMatrix().scaleSelf(scale);
|
||||||
const renderStats = this._createRenderPassStats();
|
const renderStats = this._createRenderPassStats();
|
||||||
const fullRender = this._needsFullRender;
|
const fullRender = this._needsFullRender;
|
||||||
const stackingIndexesToRender = fullRender
|
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._stackingCanvas.map((_, idx) => idx)
|
||||||
: [...this._dirtyStackingCanvasIndexes];
|
: [...this._dirtyStackingCanvasIndexes];
|
||||||
/**
|
/**
|
||||||
@@ -589,7 +1000,15 @@ export class CanvasRenderer {
|
|||||||
*/
|
*/
|
||||||
let fallbackElement: SurfaceElementModel[] = [];
|
let fallbackElement: SurfaceElementModel[] = [];
|
||||||
const allCanvasLayers = this.layerManager.getCanvasLayers();
|
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) {
|
for (const idx of stackingIndexesToRender) {
|
||||||
const layer = allCanvasLayers[idx];
|
const layer = allCanvasLayers[idx];
|
||||||
@@ -601,7 +1020,7 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
const layerRenderBound = this._getLayerRenderBound(
|
const layerRenderBound = this._getLayerRenderBound(
|
||||||
layer.elements,
|
layer.elements,
|
||||||
viewportBound
|
stackingViewportBound
|
||||||
);
|
);
|
||||||
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
||||||
canvas,
|
canvas,
|
||||||
@@ -638,7 +1057,12 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
if (fullRender || this._mainCanvasDirty) {
|
if (fullRender || this._mainCanvasDirty) {
|
||||||
allCanvasLayers.forEach((layer, idx) => {
|
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);
|
fallbackElement = fallbackElement.concat(layer.elements);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -651,10 +1075,11 @@ export class CanvasRenderer {
|
|||||||
ctx,
|
ctx,
|
||||||
matrix,
|
matrix,
|
||||||
new RoughCanvas(ctx.canvas),
|
new RoughCanvas(ctx.canvas),
|
||||||
viewportBounds,
|
mainCanvasRenderBound,
|
||||||
fallbackElement,
|
fallbackElement,
|
||||||
true,
|
true,
|
||||||
renderStats
|
renderStats,
|
||||||
|
mainCanvasCullBound
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +1151,8 @@ export class CanvasRenderer {
|
|||||||
bound: IBound,
|
bound: IBound,
|
||||||
surfaceElements?: SurfaceElementModel[],
|
surfaceElements?: SurfaceElementModel[],
|
||||||
overLay: boolean = false,
|
overLay: boolean = false,
|
||||||
renderStats?: RenderPassStats
|
renderStats?: RenderPassStats,
|
||||||
|
cullBound: IBound = bound
|
||||||
) {
|
) {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
@@ -734,13 +1160,13 @@ export class CanvasRenderer {
|
|||||||
|
|
||||||
const elements =
|
const elements =
|
||||||
surfaceElements ??
|
surfaceElements ??
|
||||||
(this.grid.search(bound, {
|
(this.grid.search(cullBound, {
|
||||||
filter: ['canvas', 'local'],
|
filter: ['canvas', 'local'],
|
||||||
}) as SurfaceElementModel[]);
|
}) as SurfaceElementModel[]);
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
const display = (element.display ?? true) && !element.hidden;
|
const display = (element.display ?? true) && !element.hidden;
|
||||||
if (display && intersects(getBoundWithRotation(element), bound)) {
|
if (display && intersects(getBoundWithRotation(element), cullBound)) {
|
||||||
renderStats && (renderStats.visibleElementCount += 1);
|
renderStats && (renderStats.visibleElementCount += 1);
|
||||||
if (
|
if (
|
||||||
this.usePlaceholder &&
|
this.usePlaceholder &&
|
||||||
@@ -748,7 +1174,7 @@ export class CanvasRenderer {
|
|||||||
) {
|
) {
|
||||||
renderStats && (renderStats.placeholderElementCount += 1);
|
renderStats && (renderStats.placeholderElementCount += 1);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
ctx.fillStyle = resolveSurfacePlaceholderColor(this.getColorScheme());
|
||||||
const drawX = element.x - bound.x;
|
const drawX = element.x - bound.x;
|
||||||
const drawY = element.y - bound.y;
|
const drawY = element.y - bound.y;
|
||||||
ctx.fillRect(drawX, drawY, element.w, element.h);
|
ctx.fillRect(drawX, drawY, element.w, element.h);
|
||||||
@@ -785,9 +1211,12 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _resetSize() {
|
private _resetSize() {
|
||||||
const sizeUpdater = this._canvasSizeUpdater();
|
const sizeUpdater = this._canvasSizeUpdater(
|
||||||
|
this.viewport.overscanViewportBounds
|
||||||
|
);
|
||||||
|
|
||||||
sizeUpdater.update(this.canvas);
|
sizeUpdater.update(this.canvas);
|
||||||
|
this._lastCanvasBudgetZoom = this.viewport.zoom;
|
||||||
this._invalidate({ type: 'all' });
|
this._invalidate({ type: 'all' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +1267,7 @@ export class CanvasRenderer {
|
|||||||
this._container = container;
|
this._container = container;
|
||||||
container.append(this.canvas);
|
container.append(this.canvas);
|
||||||
|
|
||||||
|
this._updatePlaceholderMode();
|
||||||
this._resetSize();
|
this._resetSize();
|
||||||
this.refresh({ type: 'all' });
|
this.refresh({ type: 'all' });
|
||||||
}
|
}
|
||||||
@@ -864,8 +1294,11 @@ export class CanvasRenderer {
|
|||||||
canvas = canvas || document.createElement('canvas');
|
canvas = canvas || document.createElement('canvas');
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr;
|
const actualWidth = Math.ceil(bound.w * dpr);
|
||||||
if (canvas.height !== bound.h * dpr) canvas.height = bound.h * 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.width = `${bound.w}px`;
|
||||||
canvas.style.height = `${bound.h}px`;
|
canvas.style.height = `${bound.h}px`;
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import type {
|
|||||||
SurfaceBlockModel,
|
SurfaceBlockModel,
|
||||||
Viewport,
|
Viewport,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
|
import { viewportRuntimeConfig } from '@blocksuite/std/gfx';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import type { SurfaceElementModel } from '../element-model/base.js';
|
import type { SurfaceElementModel } from '../element-model/base.js';
|
||||||
import type { DomElementRenderer } from './dom-elements/index.js';
|
import type { DomElementRenderer } from './dom-elements/index.js';
|
||||||
import { DomElementRendererIdentifier } from './dom-elements/index.js';
|
import { DomElementRendererIdentifier } from './dom-elements/index.js';
|
||||||
import type { Overlay } from './overlay.js';
|
import type { Overlay } from './overlay.js';
|
||||||
|
import { resolveSurfacePlaceholderColor } from './placeholder-style.js';
|
||||||
|
|
||||||
type EnvProvider = {
|
type EnvProvider = {
|
||||||
generateColorProperty: (color: Color, fallback?: Color) => string;
|
generateColorProperty: (color: Color, fallback?: Color) => string;
|
||||||
@@ -222,6 +224,12 @@ export class DomRenderer {
|
|||||||
private _initViewport() {
|
private _initViewport() {
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => {
|
this.viewport.viewportUpdated.subscribe(() => {
|
||||||
|
if (
|
||||||
|
this.viewport.SKIP_REFRESH_DURING_GESTURE &&
|
||||||
|
(this.viewport.panning$.value || this.viewport.zooming$.value)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._markViewportDirty();
|
this._markViewportDirty();
|
||||||
this.refresh();
|
this.refresh();
|
||||||
})
|
})
|
||||||
@@ -242,6 +250,9 @@ export class DomRenderer {
|
|||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
this.viewport.zooming$.subscribe(isZooming => {
|
this.viewport.zooming$.subscribe(isZooming => {
|
||||||
|
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
const shouldRenderPlaceholders = this._turboEnabled() && isZooming;
|
||||||
|
|
||||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||||
@@ -252,6 +263,43 @@ export class DomRenderer {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Post-gesture refresh for SKIP mode
|
||||||
|
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
|
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const cancelRefresh = () => {
|
||||||
|
if (pendingTimerId !== null) {
|
||||||
|
clearTimeout(pendingTimerId);
|
||||||
|
pendingTimerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
cancelRefresh();
|
||||||
|
pendingTimerId = setTimeout(() => {
|
||||||
|
pendingTimerId = null;
|
||||||
|
if (!this.viewport.panning$.value && !this.viewport.zooming$.value) {
|
||||||
|
this._markViewportDirty();
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._disposables.add(
|
||||||
|
this.viewport.panning$.subscribe(panning => {
|
||||||
|
if (panning) cancelRefresh();
|
||||||
|
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this._disposables.add(
|
||||||
|
this.viewport.zooming$.subscribe(zooming => {
|
||||||
|
if (zooming) cancelRefresh();
|
||||||
|
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this._disposables.add({ dispose: cancelRefresh });
|
||||||
|
}
|
||||||
|
|
||||||
this.usePlaceholder = false;
|
this.usePlaceholder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +340,15 @@ export class DomRenderer {
|
|||||||
domElement = document.createElement('div');
|
domElement = document.createElement('div');
|
||||||
domElement.dataset.elementId = elementModel.id;
|
domElement.dataset.elementId = elementModel.id;
|
||||||
domElement.style.position = 'absolute';
|
domElement.style.position = 'absolute';
|
||||||
domElement.style.backgroundColor = 'rgba(200, 200, 200, 0.5)';
|
|
||||||
this._elementsMap.set(elementModel.id, domElement);
|
this._elementsMap.set(elementModel.id, domElement);
|
||||||
this.rootElement.append(domElement);
|
this.rootElement.append(domElement);
|
||||||
addedElements.push(domElement);
|
addedElements.push(domElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
domElement.style.backgroundColor = resolveSurfacePlaceholderColor(
|
||||||
|
this.getColorScheme()
|
||||||
|
);
|
||||||
|
|
||||||
const geometricStyles = calculatePlaceholderRect(
|
const geometricStyles = calculatePlaceholderRect(
|
||||||
elementModel,
|
elementModel,
|
||||||
viewportBounds,
|
viewportBounds,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { type ColorScheme } from '@blocksuite/affine-model';
|
||||||
|
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||||
|
|
||||||
|
export function getSurfacePlaceholderFallback(colorScheme: ColorScheme) {
|
||||||
|
return getAffinePlaceholderFillColor(colorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSurfacePlaceholderColor(colorScheme: ColorScheme) {
|
||||||
|
return getSurfacePlaceholderFallback(colorScheme);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type LocalConnectorElementModel,
|
type LocalConnectorElementModel,
|
||||||
type PointStyle,
|
type PointStyle,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
|
import { getAffinePlaceholderFillColor } from '@blocksuite/affine-shared/theme';
|
||||||
import {
|
import {
|
||||||
getBezierParameters,
|
getBezierParameters,
|
||||||
type PointLocation,
|
type PointLocation,
|
||||||
@@ -253,7 +254,7 @@ function renderLabel(
|
|||||||
ctx.setTransform(matrix);
|
ctx.setTransform(matrix);
|
||||||
|
|
||||||
if (renderer.usePlaceholder) {
|
if (renderer.usePlaceholder) {
|
||||||
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
ctx.fillStyle = getAffinePlaceholderFillColor(renderer.getColorScheme());
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
return; // Skip actual label rendering
|
return; // Skip actual label rendering
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"author": "toeverything",
|
"author": "toeverything",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import {
|
||||||
|
getAffinePlaceholderFillColor,
|
||||||
|
getAffinePlaceholderStrokeColor,
|
||||||
|
inferColorSchemeFromThemeMode,
|
||||||
|
} from '@blocksuite/affine-shared/theme';
|
||||||
import type { EditorHost, GfxBlockComponent } from '@blocksuite/std';
|
import type { 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 type { BlockModel } from '@blocksuite/store';
|
||||||
|
|
||||||
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
import { BlockLayoutHandlersIdentifier } from './layout/block-layout-provider';
|
||||||
@@ -10,9 +15,13 @@ import type {
|
|||||||
ViewportLayoutTree,
|
ViewportLayoutTree,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export function syncCanvasSize(canvas: HTMLCanvasElement, host: HTMLElement) {
|
export function syncCanvasSize(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
host: HTMLElement,
|
||||||
|
zoom = 1
|
||||||
|
) {
|
||||||
const hostRect = host.getBoundingClientRect();
|
const hostRect = host.getBoundingClientRect();
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = getEffectiveDpr(zoom);
|
||||||
canvas.style.position = 'absolute';
|
canvas.style.position = 'absolute';
|
||||||
canvas.style.left = '0px';
|
canvas.style.left = '0px';
|
||||||
canvas.style.top = '0px';
|
canvas.style.top = '0px';
|
||||||
@@ -186,21 +195,21 @@ export function paintPlaceholder(
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx || !layout) return;
|
if (!ctx || !layout) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = getEffectiveDpr(viewport.zoom);
|
||||||
const { overallRect } = layout;
|
const { overallRect } = layout;
|
||||||
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
|
const layoutViewCoord = viewport.toViewCoord(overallRect.x, overallRect.y);
|
||||||
|
|
||||||
const offsetX = layoutViewCoord[0];
|
const offsetX = layoutViewCoord[0];
|
||||||
const offsetY = layoutViewCoord[1];
|
const offsetY = layoutViewCoord[1];
|
||||||
const colors = [
|
const colorScheme = inferColorSchemeFromThemeMode(
|
||||||
'rgba(200, 200, 200, 0.7)',
|
document.documentElement.dataset.theme
|
||||||
'rgba(180, 180, 180, 0.7)',
|
);
|
||||||
'rgba(160, 160, 160, 0.7)',
|
const fillColor = getAffinePlaceholderFillColor(colorScheme);
|
||||||
];
|
const strokeColor = getAffinePlaceholderStrokeColor(colorScheme);
|
||||||
|
|
||||||
const paintNode = (node: BlockLayoutTreeNode, depth: number = 0) => {
|
const paintNode = (node: BlockLayoutTreeNode) => {
|
||||||
const { layout: nodeLayout } = node;
|
const { layout: nodeLayout } = node;
|
||||||
ctx.fillStyle = colors[depth % colors.length];
|
ctx.fillStyle = fillColor;
|
||||||
const rect = nodeLayout.rect;
|
const rect = nodeLayout.rect;
|
||||||
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
|
const x = ((rect.x - overallRect.x) * viewport.zoom + offsetX) * dpr;
|
||||||
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
|
const y = ((rect.y - overallRect.y) * viewport.zoom + offsetY) * dpr;
|
||||||
@@ -209,12 +218,12 @@ export function paintPlaceholder(
|
|||||||
|
|
||||||
ctx.fillRect(x, y, width, height);
|
ctx.fillRect(x, y, width, height);
|
||||||
if (width > 10 && height > 5) {
|
if (width > 10 && height > 5) {
|
||||||
ctx.strokeStyle = 'rgba(150, 150, 150, 0.3)';
|
ctx.strokeStyle = strokeColor;
|
||||||
ctx.strokeRect(x, y, width, height);
|
ctx.strokeRect(x, y, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
node.children.forEach(childNode => paintNode(childNode, depth + 1));
|
node.children.forEach(childNode => paintNode(childNode));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { Container } from '@blocksuite/global/di';
|
import type { Container } from '@blocksuite/global/di';
|
||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
|
import { IS_IOS } from '@blocksuite/global/env';
|
||||||
import { ConfigExtensionFactory } from '@blocksuite/std';
|
import { ConfigExtensionFactory } from '@blocksuite/std';
|
||||||
import {
|
import {
|
||||||
|
getEffectiveDpr,
|
||||||
type GfxController,
|
type GfxController,
|
||||||
GfxExtension,
|
GfxExtension,
|
||||||
GfxExtensionIdentifier,
|
GfxExtensionIdentifier,
|
||||||
type GfxViewportElement,
|
type GfxViewportElement,
|
||||||
|
viewportRuntimeConfig,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
@@ -34,6 +37,26 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const debug = false; // Toggle for debug logs
|
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 = {
|
const defaultOptions = {
|
||||||
zoomThreshold: 1, // With high enough zoom, fallback to DOM rendering
|
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.viewport.elementReady.pipe(take(1)).subscribe(element => {
|
||||||
this.viewportElement = element;
|
this.viewportElement = element;
|
||||||
syncCanvasSize(this.canvas, this.std.host);
|
syncCanvasSize(this.canvas, this.std.host, this.viewport.zoom);
|
||||||
this.state$.next('pending');
|
this.state$.next('pending');
|
||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
@@ -156,6 +179,12 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => {
|
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);
|
this.refresh().catch(console.error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -166,7 +195,9 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
tap(isZooming => {
|
tap(isZooming => {
|
||||||
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
this.debugLog(`Zooming signal changed: ${isZooming}`);
|
||||||
if (isZooming) {
|
if (isZooming) {
|
||||||
|
if (!this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
this.state$.next('zooming');
|
this.state$.next('zooming');
|
||||||
|
}
|
||||||
} else if (this.state$.value === 'zooming') {
|
} else if (this.state$.value === 'zooming') {
|
||||||
this.clearOptimizedBlocks();
|
this.clearOptimizedBlocks();
|
||||||
this.isRecentlyZoomed$.next(true);
|
this.isRecentlyZoomed$.next(true);
|
||||||
@@ -183,6 +214,45 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
)
|
)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Post-gesture refresh for SKIP mode
|
||||||
|
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
|
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const cancelRefresh = () => {
|
||||||
|
if (pendingTimerId !== null) {
|
||||||
|
clearTimeout(pendingTimerId);
|
||||||
|
pendingTimerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
cancelRefresh();
|
||||||
|
pendingTimerId = setTimeout(() => {
|
||||||
|
pendingTimerId = null;
|
||||||
|
if (
|
||||||
|
!this.viewport.panning$.value &&
|
||||||
|
!this.viewport.zooming$.value
|
||||||
|
) {
|
||||||
|
this.refresh().catch(console.error);
|
||||||
|
}
|
||||||
|
}, viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
this.viewport.panning$.subscribe(panning => {
|
||||||
|
if (panning) cancelRefresh();
|
||||||
|
else if (!this.viewport.zooming$.value) scheduleRefresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.disposables.add(
|
||||||
|
this.viewport.zooming$.subscribe(zooming => {
|
||||||
|
if (zooming) cancelRefresh();
|
||||||
|
else if (!this.viewport.panning$.value) scheduleRefresh();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.disposables.add({ dispose: cancelRefresh });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle selection and block updates
|
// Handle selection and block updates
|
||||||
@@ -235,10 +305,22 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
nextState = 'pending';
|
nextState = 'pending';
|
||||||
this.clearOptimizedBlocks();
|
this.clearOptimizedBlocks();
|
||||||
} else if (this.isZooming()) {
|
} else if (this.isZooming()) {
|
||||||
this.debugLog('Currently zooming, using placeholder rendering');
|
|
||||||
nextState = 'zooming';
|
nextState = 'zooming';
|
||||||
|
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.paintPlaceholder();
|
||||||
this.updateOptimizedBlocks();
|
this.updateOptimizedBlocks();
|
||||||
|
}
|
||||||
} else if (this.canUseBitmapCache()) {
|
} else if (this.canUseBitmapCache()) {
|
||||||
this.debugLog('Using cached bitmap');
|
this.debugLog('Using cached bitmap');
|
||||||
nextState = 'ready';
|
nextState = 'ready';
|
||||||
@@ -286,7 +368,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layout = this.layoutCache;
|
const layout = this.layoutCache;
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||||
const currentVersion = this.layoutVersion;
|
const currentVersion = this.layoutVersion;
|
||||||
|
|
||||||
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
this.debugLog(`Requesting bitmap painting (version=${currentVersion})`);
|
||||||
@@ -368,12 +450,14 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
layout.overallRect.y
|
layout.overallRect.y
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dpr = getEffectiveDpr(this.viewport.zoom);
|
||||||
|
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
bitmap,
|
bitmap,
|
||||||
layoutViewCoord[0] * window.devicePixelRatio,
|
layoutViewCoord[0] * dpr,
|
||||||
layoutViewCoord[1] * window.devicePixelRatio,
|
layoutViewCoord[1] * dpr,
|
||||||
layout.overallRect.w * window.devicePixelRatio * this.viewport.zoom,
|
layout.overallRect.w * dpr * this.viewport.zoom,
|
||||||
layout.overallRect.h * window.devicePixelRatio * this.viewport.zoom
|
layout.overallRect.h * dpr * this.viewport.zoom
|
||||||
);
|
);
|
||||||
|
|
||||||
this.debugLog('Bitmap drawn to canvas');
|
this.debugLog('Bitmap drawn to canvas');
|
||||||
@@ -389,6 +473,16 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
|
|
||||||
private updateOptimizedBlocks() {
|
private updateOptimizedBlocks() {
|
||||||
if (!this.canOptimize()) return;
|
if (!this.canOptimize()) return;
|
||||||
|
if (
|
||||||
|
!shouldIdleTurboBlocksDuringZooming({
|
||||||
|
isIOS: IS_IOS,
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
this.clearOptimizedBlocks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!this.viewportElement || !this.layoutCache) return;
|
if (!this.viewportElement || !this.layoutCache) return;
|
||||||
const blockElements = this.viewportElement.getModelsInViewport();
|
const blockElements = this.viewportElement.getModelsInViewport();
|
||||||
@@ -416,7 +510,7 @@ export class ViewportTurboRendererExtension extends GfxExtension {
|
|||||||
|
|
||||||
private handleResize() {
|
private handleResize() {
|
||||||
this.debugLog('Container resized, syncing canvas size');
|
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.invalidate();
|
||||||
this.refresh$.next();
|
this.refresh$.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../shared" },
|
||||||
{ "path": "../../../framework/global" },
|
{ "path": "../../../framework/global" },
|
||||||
{ "path": "../../../framework/std" },
|
{ "path": "../../../framework/std" },
|
||||||
{ "path": "../../../framework/store" }
|
{ "path": "../../../framework/store" }
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './css-variables.js';
|
export * from './css-variables.js';
|
||||||
|
export * from './placeholder-style.js';
|
||||||
|
|||||||
19
blocksuite/affine/shared/src/theme/placeholder-style.ts
Normal file
19
blocksuite/affine/shared/src/theme/placeholder-style.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ColorScheme } from '@blocksuite/affine-model';
|
||||||
|
|
||||||
|
export function inferColorSchemeFromThemeMode(
|
||||||
|
themeMode?: string | null
|
||||||
|
): ColorScheme {
|
||||||
|
return themeMode === 'dark' ? ColorScheme.Dark : ColorScheme.Light;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAffinePlaceholderFillColor(colorScheme: ColorScheme) {
|
||||||
|
return colorScheme === ColorScheme.Dark
|
||||||
|
? 'rgba(255, 255, 255, 0.08)'
|
||||||
|
: 'rgba(0, 0, 0, 0.04)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAffinePlaceholderStrokeColor(colorScheme: ColorScheme) {
|
||||||
|
return colorScheme === ColorScheme.Dark
|
||||||
|
? 'rgba(255, 255, 255, 0.04)'
|
||||||
|
: 'rgba(0, 0, 0, 0.02)';
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import {
|
|||||||
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
||||||
AffineEdgelessZoomToolbarWidget,
|
AffineEdgelessZoomToolbarWidget,
|
||||||
} from '.';
|
} from '.';
|
||||||
|
import { MobileZoomRuler } from './mobile-zoom-ruler';
|
||||||
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
|
import { ZoomBarToggleButton } from './zoom-bar-toggle-button';
|
||||||
import { EdgelessZoomToolbar } from './zoom-toolbar';
|
import { EdgelessZoomToolbar } from './zoom-toolbar';
|
||||||
|
|
||||||
export function effects() {
|
export function effects() {
|
||||||
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
|
customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar);
|
||||||
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
|
customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton);
|
||||||
|
customElements.define('mobile-zoom-ruler', MobileZoomRuler);
|
||||||
customElements.define(
|
customElements.define(
|
||||||
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
|
||||||
AffineEdgelessZoomToolbarWidget
|
AffineEdgelessZoomToolbarWidget
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||||
|
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||||
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
|
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/std';
|
||||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||||
import { effect } from '@preact/signals-core';
|
import { effect } from '@preact/signals-core';
|
||||||
@@ -14,15 +15,20 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
|
|||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: var(--affine-edgeless-zoom-toolbar-bottom, 20px);
|
||||||
left: 12px;
|
left: 12px;
|
||||||
z-index: var(--affine-z-index-popover);
|
z-index: var(--affine-z-index-popover);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mobile-zoom-ruler {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@container viewport (width <= 1200px) {
|
@container viewport (width <= 1200px) {
|
||||||
edgeless-zoom-toolbar {
|
edgeless-zoom-toolbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -73,10 +79,14 @@ export class AffineEdgelessZoomToolbarWidget extends WidgetComponent<RootBlockMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (this._hide || !this.edgeless) {
|
if (this._hide) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_MOBILE) {
|
||||||
|
return html`<mobile-zoom-ruler .std=${this.std}></mobile-zoom-ruler>`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
|
<edgeless-zoom-toolbar .std=${this.std}></edgeless-zoom-toolbar>
|
||||||
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
|
<zoom-bar-toggle-button .std=${this.std}></zoom-bar-toggle-button>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||||
|
import { WithDisposable } from '@blocksuite/global/lit';
|
||||||
|
import { ViewBarIcon } from '@blocksuite/icons/lit';
|
||||||
|
import type { BlockStdScope } from '@blocksuite/std';
|
||||||
|
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||||
|
import { baseTheme } from '@toeverything/theme';
|
||||||
|
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact zoom indicator for narrow / mobile edgeless viewports.
|
||||||
|
* Shows the live zoom percentage and a fit-to-screen action in a pill HUD
|
||||||
|
* anchored to the bottom-left of the canvas.
|
||||||
|
*/
|
||||||
|
export class MobileZoomRuler extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
pointer-events: auto;
|
||||||
|
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--affine-background-overlay-panel-color);
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: var(--affine-shadow-1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--affine-border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--affine-icon-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-button:hover:not(:disabled) {
|
||||||
|
background: var(--affine-hover-color);
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--affine-text-disable-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fit-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
get gfx() {
|
||||||
|
return this.std.get(GfxControllerIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewport() {
|
||||||
|
return this.gfx.viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
get zoom() {
|
||||||
|
if (!this.viewport) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return this.viewport.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
override firstUpdated() {
|
||||||
|
const { disposables } = this;
|
||||||
|
const viewport = this.viewport;
|
||||||
|
if (!viewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disposables.add(
|
||||||
|
viewport.viewportUpdated.subscribe(() => this.requestUpdate())
|
||||||
|
);
|
||||||
|
disposables.add(viewport.zoomUpdated.subscribe(() => this.requestUpdate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const formattedZoom = `${Math.round(this.zoom * 100)}%`;
|
||||||
|
const locked = this.viewport?.locked || this.std.store.readonly;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="zoom-pill"
|
||||||
|
@dblclick=${stopPropagation}
|
||||||
|
@mousedown=${stopPropagation}
|
||||||
|
@mouseup=${stopPropagation}
|
||||||
|
@pointerdown=${stopPropagation}
|
||||||
|
>
|
||||||
|
<span class="zoom-label">${formattedZoom}</span>
|
||||||
|
<span class="divider"></span>
|
||||||
|
<button
|
||||||
|
class="fit-button"
|
||||||
|
aria-label="Fit to screen"
|
||||||
|
?disabled=${locked}
|
||||||
|
@click=${() => this.gfx.fitToScreen()}
|
||||||
|
>
|
||||||
|
${ViewBarIcon()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor std!: BlockStdScope;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
- [gfxGroupCompatibleSymbol](variables/gfxGroupCompatibleSymbol.md)
|
- [gfxGroupCompatibleSymbol](variables/gfxGroupCompatibleSymbol.md)
|
||||||
- [SURFACE\_TEXT\_UNIQ\_IDENTIFIER](variables/SURFACE_TEXT_UNIQ_IDENTIFIER.md)
|
- [SURFACE\_TEXT\_UNIQ\_IDENTIFIER](variables/SURFACE_TEXT_UNIQ_IDENTIFIER.md)
|
||||||
- [SURFACE\_YMAP\_UNIQ\_IDENTIFIER](variables/SURFACE_YMAP_UNIQ_IDENTIFIER.md)
|
- [SURFACE\_YMAP\_UNIQ\_IDENTIFIER](variables/SURFACE_YMAP_UNIQ_IDENTIFIER.md)
|
||||||
|
- [viewportRuntimeConfig](variables/viewportRuntimeConfig.md)
|
||||||
|
|
||||||
## Functions
|
## Functions
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
- [generateKeyBetween](functions/generateKeyBetween.md)
|
- [generateKeyBetween](functions/generateKeyBetween.md)
|
||||||
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
||||||
- [generateNKeysBetween](functions/generateNKeysBetween.md)
|
- [generateNKeysBetween](functions/generateNKeysBetween.md)
|
||||||
|
- [getEffectiveDpr](functions/getEffectiveDpr.md)
|
||||||
- [getTopElements](functions/getTopElements.md)
|
- [getTopElements](functions/getTopElements.md)
|
||||||
- [GfxCompatible](functions/GfxCompatible.md)
|
- [GfxCompatible](functions/GfxCompatible.md)
|
||||||
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / getEffectiveDpr
|
||||||
|
|
||||||
|
# Function: getEffectiveDpr()
|
||||||
|
|
||||||
|
> **getEffectiveDpr**(`zoom`, `rawDpr`): `number`
|
||||||
|
|
||||||
|
Resolves the effective device-pixel-ratio for canvas backing stores at the
|
||||||
|
given zoom, honoring [viewportRuntimeConfig.CANVAS\_DPR\_CAP\_BY\_ZOOM](../variables/viewportRuntimeConfig.md#canvas_dpr_cap_by_zoom).
|
||||||
|
|
||||||
|
Returns the raw `window.devicePixelRatio` when no cap applies.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### zoom
|
||||||
|
|
||||||
|
`number`
|
||||||
|
|
||||||
|
### rawDpr
|
||||||
|
|
||||||
|
`number` = `window.devicePixelRatio`
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`number`
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / viewportRuntimeConfig
|
||||||
|
|
||||||
|
# Variable: viewportRuntimeConfig
|
||||||
|
|
||||||
|
> `const` **viewportRuntimeConfig**: `object`
|
||||||
|
|
||||||
|
Process-wide defaults applied to every Viewport at construction.
|
||||||
|
|
||||||
|
Platforms that need different behavior (e.g. mobile/iOS, which must clamp the
|
||||||
|
zoom floor and defer DOM mutations during gestures to avoid WKWebView process
|
||||||
|
termination) override these once at startup, before any editor mounts. This
|
||||||
|
guarantees both the editor and the readonly preview viewports are born with
|
||||||
|
the same limits — avoiding the race and wrong-instance problems of patching a
|
||||||
|
single Viewport asynchronously after it has already mounted.
|
||||||
|
|
||||||
|
Desktop leaves these untouched, so its behavior is unchanged.
|
||||||
|
|
||||||
|
## Type Declaration
|
||||||
|
|
||||||
|
### CANVAS\_DPR\_CAP\_BY\_ZOOM
|
||||||
|
|
||||||
|
> **CANVAS\_DPR\_CAP\_BY\_ZOOM**: \[`number`, `number`\][]
|
||||||
|
|
||||||
|
Caps the canvas backing-store device-pixel-ratio at low zoom.
|
||||||
|
|
||||||
|
Each entry is `[zoomThreshold, dprCap]`, sorted ascending by threshold.
|
||||||
|
When the live zoom is below a threshold, the corresponding cap bounds the
|
||||||
|
effective dpr used to size canvases. Far-out zoom makes content tiny on
|
||||||
|
screen, so a full retina backing store is wasted memory — on iOS that waste
|
||||||
|
is what pushes WKWebView past its compositing budget and crashes the web
|
||||||
|
content process during pan/zoom.
|
||||||
|
|
||||||
|
Empty (the desktop default) means no cap: canvases always use the raw
|
||||||
|
`window.devicePixelRatio`, so desktop behavior is unchanged.
|
||||||
|
|
||||||
|
### LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT
|
||||||
|
|
||||||
|
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_BLOCK\_LIMIT**: `number` = `0`
|
||||||
|
|
||||||
|
During low-zoom gesture survival mode, keep only a tiny subset of DOM blocks
|
||||||
|
as real active DOM (selected + a few nearby blocks). `0` keeps the legacy
|
||||||
|
behavior where every viewport block remains visually mounted as `survival`.
|
||||||
|
|
||||||
|
### LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO
|
||||||
|
|
||||||
|
> **LOW\_ZOOM\_GESTURE\_ACTIVE\_DISTANCE\_RATIO**: `number` = `0.35`
|
||||||
|
|
||||||
|
Distance threshold (as a fraction of the viewport's shorter side) used to
|
||||||
|
decide whether an unselected viewport block counts as "nearby" to the
|
||||||
|
current selection during low-zoom gesture survival mode.
|
||||||
|
|
||||||
|
### OVERSCAN\_RATIO
|
||||||
|
|
||||||
|
> **OVERSCAN\_RATIO**: `number` = `0`
|
||||||
|
|
||||||
|
Fraction by which the *render/activation* viewport bound is enlarged on
|
||||||
|
every side (see Viewport.overscanViewportBounds). Pre-painting a
|
||||||
|
margin around the visible area means moderate pan/zoom gestures move into
|
||||||
|
content that is already mounted and rasterized, so it does not blank out
|
||||||
|
and wait for the post-gesture refresh.
|
||||||
|
|
||||||
|
Memory grows by roughly `(1 + 2 * ratio) ** 2`, so this must stay modest
|
||||||
|
and be paired with a zoom floor + dpr cap on mobile. `0` (desktop default)
|
||||||
|
makes Viewport.overscanViewportBounds identical to
|
||||||
|
Viewport.viewportBounds, leaving desktop behavior unchanged.
|
||||||
|
|
||||||
|
This governs the *canvas* render bound only (see
|
||||||
|
Viewport.overscanViewportBounds). It enlarges the canvas backing
|
||||||
|
stores, so memory grows with the overscan area. Keep it modest and pair it
|
||||||
|
with the mobile zoom floor + dpr cap so connectors/elements stay painted
|
||||||
|
through a gesture without pushing WKWebView over budget.
|
||||||
|
|
||||||
|
### OVERSCAN\_RATIO\_BLOCK
|
||||||
|
|
||||||
|
> **OVERSCAN\_RATIO\_BLOCK**: `number` = `0`
|
||||||
|
|
||||||
|
Like [OVERSCAN\_RATIO](#overscan_ratio) but for the *DOM block mounting* bound (see
|
||||||
|
Viewport.overscanBlockBounds). This one is expensive: every
|
||||||
|
mounted block becomes its own composited layer subtree in the WebContent
|
||||||
|
process, so enlarging it multiplies resident memory and is what pushes the
|
||||||
|
process toward an iOS jetsam kill. Keep this small (or `0`) even when
|
||||||
|
[OVERSCAN\_RATIO](#overscan_ratio) is generous. `0` (desktop default) leaves block
|
||||||
|
mounting on the exact visible bound, unchanged from upstream.
|
||||||
|
|
||||||
|
### POST\_GESTURE\_REFRESH\_DELAY
|
||||||
|
|
||||||
|
> **POST\_GESTURE\_REFRESH\_DELAY**: `number` = `800`
|
||||||
|
|
||||||
|
Delay (ms) before the post-gesture refresh repaints canvases and reactivates
|
||||||
|
blocks, used only when [SKIP\_REFRESH\_DURING\_GESTURE](#skip_refresh_during_gesture) is true. The same
|
||||||
|
value drives both the canvas and block refresh timers so they fire together
|
||||||
|
(avoiding the "blocks appear, then connectors" staggered reveal). Desktop
|
||||||
|
never enters that code path, so this is mobile-only.
|
||||||
|
|
||||||
|
### SKIP\_REFRESH\_DURING\_GESTURE
|
||||||
|
|
||||||
|
> **SKIP\_REFRESH\_DURING\_GESTURE**: `boolean` = `false`
|
||||||
|
|
||||||
|
### VIEWPORT\_REFRESH\_MAX\_INTERVAL
|
||||||
|
|
||||||
|
> **VIEWPORT\_REFRESH\_MAX\_INTERVAL**: `number` = `120`
|
||||||
|
|
||||||
|
### VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD
|
||||||
|
|
||||||
|
> **VIEWPORT\_REFRESH\_PIXEL\_THRESHOLD**: `number` = `18`
|
||||||
|
|
||||||
|
### ZOOM\_MAX
|
||||||
|
|
||||||
|
> **ZOOM\_MAX**: `number`
|
||||||
|
|
||||||
|
### ZOOM\_MIN
|
||||||
|
|
||||||
|
> **ZOOM\_MIN**: `number`
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import type { SerializedXYWH } from '@blocksuite/global/gfx';
|
||||||
import {
|
import {
|
||||||
createAutoIncrementIdGenerator,
|
createAutoIncrementIdGenerator,
|
||||||
TestWorkspace,
|
TestWorkspace,
|
||||||
} from '@blocksuite/store/test';
|
} from '@blocksuite/store/test';
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { effects } from '../../effects.js';
|
import { effects } from '../../effects.js';
|
||||||
import { GfxControllerIdentifier } from '../../gfx/identifiers.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 { TestEditorContainer } from '../test-editor.js';
|
||||||
import { TestLocalElement } from '../test-gfx-element.js';
|
import { TestLocalElement } from '../test-gfx-element.js';
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +60,7 @@ const commonSetup = async () => {
|
|||||||
const gfx = editorContainer.std.get(GfxControllerIdentifier);
|
const gfx = editorContainer.std.get(GfxControllerIdentifier);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
editorContainer,
|
||||||
gfx,
|
gfx,
|
||||||
surfaceId,
|
surfaceId,
|
||||||
rootId,
|
rootId,
|
||||||
@@ -59,6 +68,74 @@ const commonSetup = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const waitGfxViewConnected = (gfx: {
|
||||||
|
std: {
|
||||||
|
view: {
|
||||||
|
viewUpdated: {
|
||||||
|
subscribe: (
|
||||||
|
callback: (payload: {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
method: string;
|
||||||
|
}) => void
|
||||||
|
) => { unsubscribe: () => void };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
return (id: string) => {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
|
||||||
|
if (
|
||||||
|
payload.id === id &&
|
||||||
|
payload.type === 'block' &&
|
||||||
|
payload.method === 'add'
|
||||||
|
) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTestGfxBlockModel = (
|
||||||
|
gfx: { getElementById: (id: string) => unknown },
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
const model = gfx.getElementById(id) as GfxBlockElementModel | null;
|
||||||
|
if (!model) {
|
||||||
|
throw new Error(`Missing gfx model for block ${id}`);
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTestGfxBlockView = (
|
||||||
|
gfx: { view: { get: (id: string) => unknown } },
|
||||||
|
id: string
|
||||||
|
) => {
|
||||||
|
const view = gfx.view.get(id) as GfxBlockComponent | null;
|
||||||
|
if (!view) {
|
||||||
|
throw new Error(`Missing gfx view for block ${id}`);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getViewportChildBlockIds = (viewportElement: GfxViewportElement) =>
|
||||||
|
[...viewportElement.children].map(
|
||||||
|
child => (child as HTMLElement).dataset.blockId
|
||||||
|
);
|
||||||
|
|
||||||
|
const setBlockXYWH = (
|
||||||
|
gfx: { getElementById: (id: string) => unknown },
|
||||||
|
id: string,
|
||||||
|
xywh: SerializedXYWH
|
||||||
|
) => {
|
||||||
|
const model = getTestGfxBlockModel(gfx, id);
|
||||||
|
model.xywh = xywh;
|
||||||
|
};
|
||||||
|
|
||||||
describe('gfx element view basic', () => {
|
describe('gfx element view basic', () => {
|
||||||
test('view should be created', async () => {
|
test('view should be created', async () => {
|
||||||
const { gfx, surfaceModel } = await commonSetup();
|
const { gfx, surfaceModel } = await commonSetup();
|
||||||
@@ -91,24 +168,10 @@ describe('gfx element view basic', () => {
|
|||||||
|
|
||||||
test('query gfx block view should work', async () => {
|
test('query gfx block view should work', async () => {
|
||||||
const { gfx, surfaceId, rootId } = await commonSetup();
|
const { gfx, surfaceId, rootId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
const waitGfxViewConnected = (id: string) => {
|
|
||||||
const { promise, resolve } = Promise.withResolvers<void>();
|
|
||||||
const subscription = gfx.std.view.viewUpdated.subscribe(payload => {
|
|
||||||
if (
|
|
||||||
payload.id === id &&
|
|
||||||
payload.type === 'block' &&
|
|
||||||
payload.method === 'add'
|
|
||||||
) {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
|
const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId);
|
||||||
await waitGfxViewConnected(id);
|
await waitViewConnected(id);
|
||||||
const gfxBlockView = gfx.view.get(id);
|
const gfxBlockView = gfx.view.get(id);
|
||||||
expect(gfxBlockView).not.toBeNull();
|
expect(gfxBlockView).not.toBeNull();
|
||||||
|
|
||||||
@@ -117,6 +180,824 @@ describe('gfx element view basic', () => {
|
|||||||
expect(rootView).toBeNull();
|
expect(rootView).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detects low-zoom DOM survival mode only during active gestures for gesture-safe viewport configs', () => {
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.4,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
gestureActive: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.4,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
gestureActive: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.6,
|
||||||
|
skipRefreshDuringGesture: true,
|
||||||
|
gestureActive: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: 0.4,
|
||||||
|
skipRefreshDuringGesture: false,
|
||||||
|
gestureActive: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps selected block active while degrading unselected low-zoom viewport blocks', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(outOfViewportModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, inViewportModel]);
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_lastVisibleModels: Set<unknown>;
|
||||||
|
}
|
||||||
|
)._lastVisibleModels = new Set([
|
||||||
|
selectedModel,
|
||||||
|
inViewportModel,
|
||||||
|
outOfViewportModel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('survival');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parks non-active low-zoom gesture blocks outside viewport DOM while gesture is running', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const nearbyId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const farVisibleId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(nearbyId),
|
||||||
|
waitViewConnected(farVisibleId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, nearbyId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, farVisibleId, '[120,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const nearbyModel = getTestGfxBlockModel(gfx, nearbyId);
|
||||||
|
const farVisibleModel = getTestGfxBlockModel(gfx, farVisibleId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const nearbyView = getTestGfxBlockView(gfx, nearbyId);
|
||||||
|
const farVisibleView = getTestGfxBlockView(gfx, farVisibleId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(nearbyModel).not.toBeNull();
|
||||||
|
expect(farVisibleModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(nearbyView).not.toBeNull();
|
||||||
|
expect(farVisibleView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||||
|
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
Object.defineProperty(shell, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 844,
|
||||||
|
});
|
||||||
|
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._shell = shell;
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedOffsetWidth = 844;
|
||||||
|
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
gfx.viewport.panning$.next(true);
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, nearbyModel, farVisibleModel]);
|
||||||
|
document.body.append(viewportElement);
|
||||||
|
viewportElement.append(
|
||||||
|
selectedView,
|
||||||
|
nearbyView,
|
||||||
|
farVisibleView,
|
||||||
|
outOfViewportView
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
|
||||||
|
expect(getViewportChildBlockIds(viewportElement)).toEqual([
|
||||||
|
selectedId,
|
||||||
|
nearbyId,
|
||||||
|
]);
|
||||||
|
expect(farVisibleView.isConnected).toBe(false);
|
||||||
|
expect(outOfViewportView.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores parked low-zoom blocks after gesture recovery completes', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const firstId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const secondId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const thirdId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(firstId),
|
||||||
|
waitViewConnected(secondId),
|
||||||
|
waitViewConnected(thirdId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, firstId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, secondId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, thirdId, '[40,0,10,10]');
|
||||||
|
|
||||||
|
const firstModel = getTestGfxBlockModel(gfx, firstId);
|
||||||
|
const secondModel = getTestGfxBlockModel(gfx, secondId);
|
||||||
|
const thirdModel = getTestGfxBlockModel(gfx, thirdId);
|
||||||
|
const firstView = getTestGfxBlockView(gfx, firstId);
|
||||||
|
const secondView = getTestGfxBlockView(gfx, secondId);
|
||||||
|
const thirdView = getTestGfxBlockView(gfx, thirdId);
|
||||||
|
|
||||||
|
expect(firstModel).not.toBeNull();
|
||||||
|
expect(secondModel).not.toBeNull();
|
||||||
|
expect(thirdModel).not.toBeNull();
|
||||||
|
expect(firstView).not.toBeNull();
|
||||||
|
expect(secondView).not.toBeNull();
|
||||||
|
expect(thirdView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.clear();
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||||
|
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
Object.defineProperty(shell, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 844,
|
||||||
|
});
|
||||||
|
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._shell = shell;
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedOffsetWidth = 844;
|
||||||
|
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
gfx.viewport.panning$.next(true);
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([firstModel, secondModel, thirdModel]);
|
||||||
|
document.body.append(viewportElement);
|
||||||
|
viewportElement.append(firstView, secondView, thirdView);
|
||||||
|
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
|
||||||
|
expect(viewportElement.children).toHaveLength(1);
|
||||||
|
|
||||||
|
gfx.viewport.panning$.next(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1200);
|
||||||
|
|
||||||
|
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
|
||||||
|
new Set([firstId, secondId, thirdId])
|
||||||
|
);
|
||||||
|
expect(firstView.transformState$.value).toBe('active');
|
||||||
|
expect(secondView.transformState$.value).toBe('active');
|
||||||
|
expect(thirdView.transformState$.value).toBe('active');
|
||||||
|
|
||||||
|
gfx.viewport.panning$.next(true);
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
expect(viewportElement.children).toHaveLength(1);
|
||||||
|
|
||||||
|
gfx.viewport.panning$.next(false);
|
||||||
|
await vi.advanceTimersByTimeAsync(1200);
|
||||||
|
|
||||||
|
expect(new Set(getViewportChildBlockIds(viewportElement))).toEqual(
|
||||||
|
new Set([firstId, secondId, thirdId])
|
||||||
|
);
|
||||||
|
expect(firstView.transformState$.value).toBe('active');
|
||||||
|
expect(secondView.transformState$.value).toBe('active');
|
||||||
|
expect(thirdView.transformState$.value).toBe('active');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('programmatic low-zoom viewport changes do not arm gesture signals', async () => {
|
||||||
|
const { Viewport } = await import('../../gfx/index.js');
|
||||||
|
|
||||||
|
const viewport = new Viewport();
|
||||||
|
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||||
|
|
||||||
|
viewport.setViewport(0.4, [20, 0]);
|
||||||
|
|
||||||
|
expect(viewport.panning$.value).toBe(false);
|
||||||
|
expect(viewport.zooming$.value).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: viewport.zoom,
|
||||||
|
skipRefreshDuringGesture: viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||||
|
gestureActive: viewport.panning$.value || viewport.zooming$.value,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('programmatic low-zoom viewport changes still emit viewport updates', async () => {
|
||||||
|
const { Viewport } = await import('../../gfx/index.js');
|
||||||
|
|
||||||
|
const viewport = new Viewport();
|
||||||
|
viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
|
||||||
|
const updates: Array<{ zoom: number; center: [number, number] }> = [];
|
||||||
|
const subscription = viewport.viewportUpdated.subscribe(
|
||||||
|
({ zoom, center }) => {
|
||||||
|
updates.push({ zoom, center: [center[0], center[1]] });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
viewport.setViewport(0.4, [20, 10]);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
expect(updates).toEqual([
|
||||||
|
{
|
||||||
|
zoom: 0.4,
|
||||||
|
center: [20, 10],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idles out-of-viewport blocks on the first visibility refresh', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, inViewportModel]);
|
||||||
|
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('active');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demotes visible unselected blocks immediately when zoom crosses into survival mode', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, inViewportModel]);
|
||||||
|
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_hideOutsideAndNoSelectedBlock: () => void;
|
||||||
|
}
|
||||||
|
)._hideOutsideAndNoSelectedBlock();
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('active');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
|
||||||
|
document.body.append(viewportElement);
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('survival');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunked low-zoom refresh idles out-of-viewport blocks on the first pass', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, inViewportModel]);
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
(
|
||||||
|
viewportElement as unknown as {
|
||||||
|
_chunkedHideOutsideAndNoSelectedBlock: (
|
||||||
|
onComplete?: () => void
|
||||||
|
) => () => void;
|
||||||
|
}
|
||||||
|
)._chunkedHideOutsideAndNoSelectedBlock(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('survival');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newly mounted blocks inherit the current low-zoom visibility state', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
await waitViewConnected(selectedId);
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
|
||||||
|
const viewportModels = new Set([selectedModel]);
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () => viewportModels;
|
||||||
|
document.body.append(viewportElement);
|
||||||
|
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const outOfViewportModel = getTestGfxBlockModel(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(outOfViewportModel).not.toBeNull();
|
||||||
|
|
||||||
|
viewportModels.add(inViewportModel);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('survival');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demotes stale active blocks immediately when low-zoom resize starts', async () => {
|
||||||
|
const { editorContainer, gfx, surfaceId } = await commonSetup();
|
||||||
|
const waitViewConnected = waitGfxViewConnected(gfx);
|
||||||
|
|
||||||
|
const selectedId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const inViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
const outOfViewportId = gfx.std.store.addBlock(
|
||||||
|
'test:gfx-block',
|
||||||
|
undefined,
|
||||||
|
surfaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
waitViewConnected(selectedId),
|
||||||
|
waitViewConnected(inViewportId),
|
||||||
|
waitViewConnected(outOfViewportId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBlockXYWH(gfx, selectedId, '[0,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, inViewportId, '[20,0,10,10]');
|
||||||
|
setBlockXYWH(gfx, outOfViewportId, '[500,500,10,10]');
|
||||||
|
|
||||||
|
const selectedModel = getTestGfxBlockModel(gfx, selectedId);
|
||||||
|
const inViewportModel = getTestGfxBlockModel(gfx, inViewportId);
|
||||||
|
const selectedView = getTestGfxBlockView(gfx, selectedId);
|
||||||
|
const inViewportView = getTestGfxBlockView(gfx, inViewportId);
|
||||||
|
const outOfViewportView = getTestGfxBlockView(gfx, outOfViewportId);
|
||||||
|
|
||||||
|
expect(selectedModel).not.toBeNull();
|
||||||
|
expect(inViewportModel).not.toBeNull();
|
||||||
|
expect(selectedView).not.toBeNull();
|
||||||
|
expect(inViewportView).not.toBeNull();
|
||||||
|
expect(outOfViewportView).not.toBeNull();
|
||||||
|
|
||||||
|
gfx.selection.set({ elements: [selectedId], editing: false });
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
|
||||||
|
const viewportElement = new GfxViewportElement();
|
||||||
|
viewportElement.host = editorContainer.std.host;
|
||||||
|
viewportElement.viewport = gfx.viewport;
|
||||||
|
viewportElement.getModelsInViewport = () =>
|
||||||
|
new Set([selectedModel, inViewportModel]);
|
||||||
|
document.body.append(viewportElement);
|
||||||
|
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
Object.defineProperty(shell, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 844,
|
||||||
|
});
|
||||||
|
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._shell = shell;
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedOffsetWidth = 844;
|
||||||
|
|
||||||
|
selectedView.transformState$.value = 'active';
|
||||||
|
inViewportView.transformState$.value = 'active';
|
||||||
|
outOfViewportView.transformState$.value = 'active';
|
||||||
|
|
||||||
|
gfx.viewport.onResize();
|
||||||
|
|
||||||
|
expect(selectedView.transformState$.value).toBe('active');
|
||||||
|
expect(inViewportView.transformState$.value).toBe('survival');
|
||||||
|
expect(outOfViewportView.transformState$.value).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resize completion clears low-zoom gesture recovery before sizeUpdated subscribers run', async () => {
|
||||||
|
const { gfx } = await commonSetup();
|
||||||
|
|
||||||
|
gfx.viewport.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
Object.defineProperty(shell, 'offsetWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 844,
|
||||||
|
});
|
||||||
|
shell.getBoundingClientRect = () => new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._shell = shell;
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedBoundingClientRect = new DOMRect(0, 0, 844, 390);
|
||||||
|
(
|
||||||
|
gfx.viewport as unknown as {
|
||||||
|
_shell: HTMLElement;
|
||||||
|
_cachedBoundingClientRect: DOMRect;
|
||||||
|
_cachedOffsetWidth: number;
|
||||||
|
}
|
||||||
|
)._cachedOffsetWidth = 844;
|
||||||
|
|
||||||
|
let panningAtSizeUpdated: boolean | null = null;
|
||||||
|
let zoomingAtSizeUpdated: boolean | null = null;
|
||||||
|
let blockSurvivalAtSizeUpdated: boolean | null = null;
|
||||||
|
let canvasRecoveryDelayAtSizeUpdated: number | null = null;
|
||||||
|
|
||||||
|
const subscription = gfx.viewport.sizeUpdated.subscribe(() => {
|
||||||
|
const gestureActive =
|
||||||
|
gfx.viewport.panning$.value || gfx.viewport.zooming$.value;
|
||||||
|
|
||||||
|
panningAtSizeUpdated = gfx.viewport.panning$.value;
|
||||||
|
zoomingAtSizeUpdated = gfx.viewport.zooming$.value;
|
||||||
|
blockSurvivalAtSizeUpdated = shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: gfx.viewport.zoom,
|
||||||
|
skipRefreshDuringGesture: gfx.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||||
|
gestureActive,
|
||||||
|
});
|
||||||
|
canvasRecoveryDelayAtSizeUpdated = getPostGestureRecoveryDelay({
|
||||||
|
isPanning: gfx.viewport.panning$.value,
|
||||||
|
isZooming: gfx.viewport.zooming$.value,
|
||||||
|
fallbackDelayMs: 800,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
gfx.viewport.setZoom(0.4, { x: 0, y: 0 }, false, true, true);
|
||||||
|
gfx.viewport.onResize();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
expect(panningAtSizeUpdated).toBe(false);
|
||||||
|
expect(zoomingAtSizeUpdated).toBe(false);
|
||||||
|
expect(blockSurvivalAtSizeUpdated).toBe(false);
|
||||||
|
expect(canvasRecoveryDelayAtSizeUpdated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('local element view should be created', async () => {
|
test('local element view should be created', async () => {
|
||||||
const { gfx, surfaceModel } = await commonSetup();
|
const { gfx, surfaceModel } = await commonSetup();
|
||||||
const localElement = new TestLocalElement(surfaceModel);
|
const localElement = new TestLocalElement(surfaceModel);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Bound } from '@blocksuite/global/gfx';
|
||||||
import { WithDisposable } from '@blocksuite/global/lit';
|
import { WithDisposable } from '@blocksuite/global/lit';
|
||||||
import { batch } from '@preact/signals-core';
|
import { batch } from '@preact/signals-core';
|
||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
@@ -11,7 +12,11 @@ import {
|
|||||||
import { PropTypes, requiredProperties } from '../view/decorators/required';
|
import { PropTypes, requiredProperties } from '../view/decorators/required';
|
||||||
import { GfxControllerIdentifier } from './identifiers';
|
import { GfxControllerIdentifier } from './identifiers';
|
||||||
import { GfxBlockElementModel } from './model/gfx-block-model';
|
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
|
* A wrapper around `requestConnectedFrame` that only calls at most once in one frame
|
||||||
@@ -37,6 +42,123 @@ export function requestThrottledConnectedFrame<
|
|||||||
}) as T;
|
}) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGestureTransformMinInterval({
|
||||||
|
isPureTranslate,
|
||||||
|
zoom,
|
||||||
|
}: {
|
||||||
|
isPureTranslate: boolean;
|
||||||
|
zoom: number;
|
||||||
|
}) {
|
||||||
|
if (!isPureTranslate) {
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoom <= 0.5 ? 32 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkipGestureTransformWrite({
|
||||||
|
isPureTranslate,
|
||||||
|
zoom,
|
||||||
|
elapsedMs,
|
||||||
|
}: {
|
||||||
|
isPureTranslate: boolean;
|
||||||
|
zoom: number;
|
||||||
|
elapsedMs: number;
|
||||||
|
}) {
|
||||||
|
const minInterval = getGestureTransformMinInterval({
|
||||||
|
isPureTranslate,
|
||||||
|
zoom,
|
||||||
|
});
|
||||||
|
|
||||||
|
return minInterval > 0 && elapsedMs < minInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
export function shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom,
|
||||||
|
skipRefreshDuringGesture,
|
||||||
|
gestureActive,
|
||||||
|
}: {
|
||||||
|
zoom: number;
|
||||||
|
skipRefreshDuringGesture: boolean;
|
||||||
|
gestureActive: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
skipRefreshDuringGesture &&
|
||||||
|
gestureActive &&
|
||||||
|
zoom <= LOW_ZOOM_BLOCK_SURVIVAL_THRESHOLD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLowZoomGestureActiveModels<
|
||||||
|
T extends { elementBound: Bound; id: string },
|
||||||
|
>({
|
||||||
|
selectedModels,
|
||||||
|
viewportModels,
|
||||||
|
viewportBounds,
|
||||||
|
nearbyActiveBlockLimit,
|
||||||
|
nearbyDistanceRatio,
|
||||||
|
}: {
|
||||||
|
selectedModels: Set<T>;
|
||||||
|
viewportModels: Set<T>;
|
||||||
|
viewportBounds: Bound;
|
||||||
|
nearbyActiveBlockLimit: number;
|
||||||
|
nearbyDistanceRatio: number;
|
||||||
|
}): Set<T> {
|
||||||
|
const activeModels = new Set<T>(selectedModels);
|
||||||
|
if (nearbyActiveBlockLimit <= 0) {
|
||||||
|
return activeModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportCenter = viewportBounds.center;
|
||||||
|
const maxNearbyDistance =
|
||||||
|
Math.min(viewportBounds.w, viewportBounds.h) * nearbyDistanceRatio;
|
||||||
|
|
||||||
|
if (selectedModels.size === 0) {
|
||||||
|
const fallback = [...viewportModels]
|
||||||
|
.sort((left, right) => {
|
||||||
|
const [leftX, leftY] = left.elementBound.center;
|
||||||
|
const [rightX, rightY] = right.elementBound.center;
|
||||||
|
const leftDistance = Math.hypot(
|
||||||
|
leftX - viewportCenter[0],
|
||||||
|
leftY - viewportCenter[1]
|
||||||
|
);
|
||||||
|
const rightDistance = Math.hypot(
|
||||||
|
rightX - viewportCenter[0],
|
||||||
|
rightY - viewportCenter[1]
|
||||||
|
);
|
||||||
|
return leftDistance - rightDistance;
|
||||||
|
})
|
||||||
|
.slice(0, nearbyActiveBlockLimit);
|
||||||
|
|
||||||
|
fallback.forEach(model => activeModels.add(model));
|
||||||
|
return activeModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCenters = [...selectedModels].map(
|
||||||
|
model => model.elementBound.center
|
||||||
|
);
|
||||||
|
|
||||||
|
const nearbyCandidates = [...viewportModels]
|
||||||
|
.filter(model => !selectedModels.has(model))
|
||||||
|
.map(model => {
|
||||||
|
const [x, y] = model.elementBound.center;
|
||||||
|
const distance = Math.min(
|
||||||
|
...selectedCenters.map(([selectedX, selectedY]) =>
|
||||||
|
Math.hypot(x - selectedX, y - selectedY)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { distance, model };
|
||||||
|
})
|
||||||
|
.filter(candidate => candidate.distance <= maxNearbyDistance)
|
||||||
|
.sort((left, right) => left.distance - right.distance)
|
||||||
|
.slice(0, nearbyActiveBlockLimit);
|
||||||
|
|
||||||
|
nearbyCandidates.forEach(candidate => activeModels.add(candidate.model));
|
||||||
|
return activeModels;
|
||||||
|
}
|
||||||
|
|
||||||
@requiredProperties({
|
@requiredProperties({
|
||||||
viewport: PropTypes.instanceOf(Viewport),
|
viewport: PropTypes.instanceOf(Viewport),
|
||||||
})
|
})
|
||||||
@@ -45,6 +167,20 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
|
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`
|
static override styles = css`
|
||||||
gfx-viewport {
|
gfx-viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -63,38 +199,163 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
contain: size layout style;
|
contain: size layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile (SKIP_REFRESH_DURING_GESTURE) drives gestures with a single
|
||||||
|
* container-level transform on <gfx-viewport>; the idle blocks never
|
||||||
|
* change their own transform during the gesture. In that mode
|
||||||
|
* 'will-change: transform' is actively harmful: WKWebView promotes every
|
||||||
|
* hidden idle block (100+) to its own compositing layer and re-transforms
|
||||||
|
* all of them each frame, producing a ~100ms main-thread/compositor stall
|
||||||
|
* that terminates the web content process. Releasing the hint lets them
|
||||||
|
* ride along as raster content of the single container layer.
|
||||||
|
* Desktop (no attribute) keeps will-change because it transforms blocks
|
||||||
|
* individually per frame, where the hint is a real win.
|
||||||
|
*/
|
||||||
|
gfx-viewport[data-skip-gesture-refresh] .block-idle {
|
||||||
|
will-change: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* CSS for active blocks participating in viewport transformations */
|
/* CSS for active blocks participating in viewport transformations */
|
||||||
.block-active {
|
.block-active {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Survival blocks stay visually mounted but stop participating in input. */
|
||||||
|
.block-survival {
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
private readonly _parkedBlockViews = new Map<
|
||||||
|
string,
|
||||||
|
{ placeholder: Comment; view: HTMLElement }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private readonly _parkedBlockFragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
private _shouldParkIdleBlocks() {
|
||||||
|
return (
|
||||||
|
shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||||
|
gestureActive:
|
||||||
|
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||||
|
}) && this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _restoreParkedBlockViews() {
|
||||||
|
this._parkedBlockViews.forEach(({ placeholder, view }) => {
|
||||||
|
if (placeholder.parentNode === this) {
|
||||||
|
placeholder.replaceWith(view);
|
||||||
|
} else if (!view.isConnected) {
|
||||||
|
this.append(view);
|
||||||
|
}
|
||||||
|
placeholder.remove();
|
||||||
|
});
|
||||||
|
this._parkedBlockViews.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _syncMountedBlockViews(
|
||||||
|
shouldRemainMounted: Set<GfxBlockElementModel>
|
||||||
|
) {
|
||||||
|
if (!this.host) return;
|
||||||
|
|
||||||
|
if (!this._shouldParkIdleBlocks()) {
|
||||||
|
this._restoreParkedBlockViews();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||||
|
gfx.std.view.views.forEach(view => {
|
||||||
|
if (!isGfxBlockComponent(view)) return;
|
||||||
|
|
||||||
|
const parked = this._parkedBlockViews.get(view.model.id);
|
||||||
|
if (shouldRemainMounted.has(view.model)) {
|
||||||
|
if (parked) {
|
||||||
|
if (parked.placeholder.parentNode === this) {
|
||||||
|
parked.placeholder.replaceWith(view);
|
||||||
|
} else if (!view.isConnected) {
|
||||||
|
this.append(view);
|
||||||
|
}
|
||||||
|
parked.placeholder.remove();
|
||||||
|
this._parkedBlockViews.delete(view.model.id);
|
||||||
|
} else if (!view.isConnected || view.parentElement !== this) {
|
||||||
|
this.append(view);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parked || view.parentElement !== this) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = document.createComment(`parked:${view.model.id}`);
|
||||||
|
this.replaceChild(placeholder, view);
|
||||||
|
this._parkedBlockFragment.append(view);
|
||||||
|
this._parkedBlockViews.set(view.model.id, {
|
||||||
|
placeholder,
|
||||||
|
view,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private readonly _hideOutsideAndNoSelectedBlock = () => {
|
private readonly _hideOutsideAndNoSelectedBlock = () => {
|
||||||
if (!this.host) return;
|
if (!this.host) return;
|
||||||
|
|
||||||
const gfx = this.host.std.get(GfxControllerIdentifier);
|
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||||
const currentViewportModels = this.getModelsInViewport();
|
const currentViewportModels = this.getModelsInViewport();
|
||||||
const currentSelectedModels = this._getSelectedModels();
|
const currentSelectedModels = this._getSelectedModels();
|
||||||
const shouldBeVisible = new Set([
|
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||||
...currentViewportModels,
|
zoom: this.viewport.zoom,
|
||||||
...currentSelectedModels,
|
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
|
const previousVisible = this._lastVisibleModels
|
||||||
? new Set(this._lastVisibleModels)
|
? new Set(this._lastVisibleModels)
|
||||||
: new Set<GfxBlockElementModel>();
|
: new Set<GfxBlockElementModel>();
|
||||||
|
const candidatesToHide = new Set(previousVisible);
|
||||||
|
|
||||||
|
if (!this._lastVisibleModels) {
|
||||||
|
this.host.std.view.views.forEach(view => {
|
||||||
|
if (!isGfxBlockComponent(view)) return;
|
||||||
|
candidatesToHide.add(view.model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
// Step 1: Activate all the blocks that should be visible
|
|
||||||
shouldBeVisible.forEach(model => {
|
shouldBeVisible.forEach(model => {
|
||||||
const view = gfx.view.get(model);
|
const view = gfx.view.get(model);
|
||||||
if (!isGfxBlockComponent(view)) return;
|
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
|
candidatesToHide.forEach(model => {
|
||||||
previousVisible.forEach(model => {
|
|
||||||
if (shouldBeVisible.has(model)) return;
|
if (shouldBeVisible.has(model)) return;
|
||||||
|
|
||||||
const view = gfx.view.get(model);
|
const view = gfx.view.get(model);
|
||||||
@@ -103,11 +364,161 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._syncMountedBlockViews(shouldBeVisible);
|
||||||
|
|
||||||
this._lastVisibleModels = shouldBeVisible;
|
this._lastVisibleModels = shouldBeVisible;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunked version of _hideOutsideAndNoSelectedBlock that processes blocks
|
||||||
|
* in batches across multiple frames to prevent memory spikes on mobile.
|
||||||
|
* Returns a cancel function.
|
||||||
|
*/
|
||||||
|
private _chunkedHideOutsideAndNoSelectedBlock(
|
||||||
|
onComplete?: () => void
|
||||||
|
): () => void {
|
||||||
|
if (!this.host) return () => {};
|
||||||
|
|
||||||
|
const gfx = this.host.std.get(GfxControllerIdentifier);
|
||||||
|
const currentViewportModels = this.getModelsInViewport();
|
||||||
|
const currentSelectedModels = this._getSelectedModels();
|
||||||
|
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||||
|
gestureActive:
|
||||||
|
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||||
|
});
|
||||||
|
const shouldLimitActiveModels =
|
||||||
|
shouldUseSurvivalMode &&
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
|
||||||
|
const limitedActiveModels = shouldLimitActiveModels
|
||||||
|
? getLowZoomGestureActiveModels({
|
||||||
|
selectedModels: currentSelectedModels,
|
||||||
|
viewportModels: currentViewportModels,
|
||||||
|
viewportBounds: this.viewport.viewportBounds,
|
||||||
|
nearbyActiveBlockLimit:
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
|
||||||
|
nearbyDistanceRatio:
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const shouldBeVisible =
|
||||||
|
limitedActiveModels ??
|
||||||
|
new Set([...currentViewportModels, ...currentSelectedModels]);
|
||||||
|
|
||||||
|
const previousVisible = this._lastVisibleModels
|
||||||
|
? new Set(this._lastVisibleModels)
|
||||||
|
: new Set<GfxBlockElementModel>();
|
||||||
|
const candidatesToHide = new Set(previousVisible);
|
||||||
|
|
||||||
|
if (!this._lastVisibleModels) {
|
||||||
|
this.host.std.view.views.forEach(view => {
|
||||||
|
if (!isGfxBlockComponent(view)) return;
|
||||||
|
candidatesToHide.add(view.model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute which blocks need activation and which need hiding
|
||||||
|
const toActivate: GfxBlockElementModel[] = [];
|
||||||
|
shouldBeVisible.forEach(model => {
|
||||||
|
if (!previousVisible.has(model)) {
|
||||||
|
toActivate.push(model);
|
||||||
|
} else {
|
||||||
|
// Already visible, just ensure state is correct
|
||||||
|
const view = gfx.view.get(model);
|
||||||
|
if (!isGfxBlockComponent(view)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetState = shouldLimitActiveModels
|
||||||
|
? 'active'
|
||||||
|
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
|
||||||
|
? 'survival'
|
||||||
|
: 'active';
|
||||||
|
if (view.transformState$.value !== targetState) {
|
||||||
|
toActivate.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toHide: GfxBlockElementModel[] = [];
|
||||||
|
candidatesToHide.forEach(model => {
|
||||||
|
if (!shouldBeVisible.has(model)) {
|
||||||
|
toHide.push(model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._lastVisibleModels = shouldBeVisible;
|
||||||
|
|
||||||
|
// Hide blocks immediately (cheap: just sets visibility:hidden)
|
||||||
|
if (toHide.length > 0) {
|
||||||
|
batch(() => {
|
||||||
|
toHide.forEach(model => {
|
||||||
|
const view = gfx.view.get(model);
|
||||||
|
if (!isGfxBlockComponent(view)) return;
|
||||||
|
view.transformState$.value = 'idle';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._syncMountedBlockViews(shouldBeVisible);
|
||||||
|
|
||||||
|
// Activate blocks in chunks to prevent memory spikes
|
||||||
|
const CHUNK_SIZE = 8;
|
||||||
|
let chunkIndex = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const processNextChunk = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const start = chunkIndex * CHUNK_SIZE;
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, toActivate.length);
|
||||||
|
|
||||||
|
if (start >= toActivate.length) {
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const model = toActivate[i];
|
||||||
|
const view = gfx.view.get(model);
|
||||||
|
if (!isGfxBlockComponent(view)) continue;
|
||||||
|
view.transformState$.value = shouldLimitActiveModels
|
||||||
|
? 'active'
|
||||||
|
: shouldUseSurvivalMode && !currentSelectedModels.has(model)
|
||||||
|
? 'survival'
|
||||||
|
: 'active';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chunkIndex++;
|
||||||
|
if (chunkIndex * CHUNK_SIZE < toActivate.length) {
|
||||||
|
rafId = requestAnimationFrame(processNextChunk);
|
||||||
|
} else {
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start first chunk immediately (synchronous for responsiveness)
|
||||||
|
if (toActivate.length > 0) {
|
||||||
|
processNextChunk();
|
||||||
|
} else {
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
||||||
|
|
||||||
|
private _pendingChunkedHideCancel: (() => void) | null = null;
|
||||||
|
|
||||||
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
|
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
|
||||||
|
|
||||||
private _lastViewportRefreshTime = 0;
|
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() {
|
private _scheduleTrailingViewportRefresh() {
|
||||||
this._clearPendingViewportRefreshTimer();
|
this._clearPendingViewportRefreshTimer();
|
||||||
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
|
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
|
||||||
this._pendingViewportRefreshTimer = null;
|
this._pendingViewportRefreshTimer = null;
|
||||||
this._lastViewportRefreshTime = performance.now();
|
this._lastViewportRefreshTime = performance.now();
|
||||||
this._refreshViewport();
|
this._refreshViewport();
|
||||||
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
|
}, this._maxInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshViewportByViewportUpdate(update: {
|
private _refreshViewportByViewportUpdate(update: {
|
||||||
zoom: number;
|
zoom: number;
|
||||||
center: [number, 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 now = performance.now();
|
||||||
const previous = this._lastViewportUpdate;
|
const previous = this._lastViewportUpdate;
|
||||||
this._lastViewportUpdate = {
|
this._lastViewportUpdate = {
|
||||||
@@ -166,13 +607,11 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
(update.center[1] - previous.center[1]) * update.zoom
|
(update.center[1] - previous.center[1]) * update.zoom
|
||||||
);
|
);
|
||||||
const timeoutReached =
|
const timeoutReached =
|
||||||
now - this._lastViewportRefreshTime >=
|
now - this._lastViewportRefreshTime >= this._maxInterval;
|
||||||
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
zoomChanged ||
|
zoomChanged ||
|
||||||
centerMovedInPixel >=
|
centerMovedInPixel >= this._pixelThreshold ||
|
||||||
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
|
|
||||||
timeoutReached
|
timeoutReached
|
||||||
) {
|
) {
|
||||||
this._clearPendingViewportRefreshTimer();
|
this._clearPendingViewportRefreshTimer();
|
||||||
@@ -197,17 +636,303 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
this._refreshViewportByViewportUpdate(update)
|
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.disposables.add(
|
||||||
this.viewport.sizeUpdated.subscribe(() => {
|
this.viewport.sizeUpdated.subscribe(() => {
|
||||||
this._clearPendingViewportRefreshTimer();
|
this._clearPendingViewportRefreshTimer();
|
||||||
this._lastViewportRefreshTime = performance.now();
|
this._lastViewportRefreshTime = performance.now();
|
||||||
|
// 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();
|
this._refreshViewport();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (!this.host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
this.host.std.view.viewUpdated.subscribe(payload => {
|
||||||
|
if (payload.type !== 'block' || payload.method !== 'add') return;
|
||||||
|
if (!isGfxBlockComponent(payload.view)) return;
|
||||||
|
|
||||||
|
const currentSelectedModels = this._getSelectedModels();
|
||||||
|
const shouldUseSurvivalMode = shouldUseLowZoomBlockSurvivalMode({
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
skipRefreshDuringGesture: this.viewport.SKIP_REFRESH_DURING_GESTURE,
|
||||||
|
gestureActive:
|
||||||
|
this.viewport.panning$.value || this.viewport.zooming$.value,
|
||||||
|
});
|
||||||
|
const isSelected = currentSelectedModels.has(payload.view.model);
|
||||||
|
const isInViewport = this.getModelsInViewport().has(payload.view.model);
|
||||||
|
const shouldLimitActiveModels =
|
||||||
|
shouldUseSurvivalMode &&
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT > 0;
|
||||||
|
const activeModels = shouldLimitActiveModels
|
||||||
|
? getLowZoomGestureActiveModels({
|
||||||
|
selectedModels: currentSelectedModels,
|
||||||
|
viewportModels: this.getModelsInViewport(),
|
||||||
|
viewportBounds: this.viewport.viewportBounds,
|
||||||
|
nearbyActiveBlockLimit:
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT,
|
||||||
|
nearbyDistanceRatio:
|
||||||
|
this.viewport.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
payload.view.transformState$.value = isSelected
|
||||||
|
? 'active'
|
||||||
|
: isInViewport
|
||||||
|
? shouldLimitActiveModels
|
||||||
|
? activeModels?.has(payload.view.model)
|
||||||
|
? 'active'
|
||||||
|
: 'idle'
|
||||||
|
: shouldUseSurvivalMode
|
||||||
|
? 'survival'
|
||||||
|
: 'active'
|
||||||
|
: 'idle';
|
||||||
|
|
||||||
|
if (shouldLimitActiveModels && this._shouldParkIdleBlocks()) {
|
||||||
|
this._syncMountedBlockViews(activeModels ?? new Set());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// When SKIP_REFRESH_DURING_GESTURE is enabled, do one final refresh
|
||||||
|
// after panning/zooming ends to sync block visibility.
|
||||||
|
// Uses setTimeout (not requestIdleCallback) to guarantee a minimum delay
|
||||||
|
// before heavy work starts. requestIdleCallback fires immediately when
|
||||||
|
// idle, which doesn't protect against the "quick pause then resume" pattern.
|
||||||
|
// Uses chunked block activation to prevent memory spikes on mobile.
|
||||||
|
// Cancel if a new gesture starts before completion.
|
||||||
|
if (this.viewport.SKIP_REFRESH_DURING_GESTURE) {
|
||||||
|
// Marks this element so the stylesheet can drop 'will-change: transform'
|
||||||
|
// from idle blocks (see styles above): in this mode the gesture is driven
|
||||||
|
// by one container transform, so per-block layer promotion is pure
|
||||||
|
// overhead and stalls WKWebView's compositor.
|
||||||
|
this.dataset.skipGestureRefresh = '';
|
||||||
|
let pendingTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let cancelChunked: (() => void) | null = null;
|
||||||
|
|
||||||
|
// --- Container-level CSS transform during gestures ---
|
||||||
|
// Instead of updating N block transforms per frame (expensive),
|
||||||
|
// apply a single CSS transform on this element that represents the
|
||||||
|
// relative zoom/pan delta from the gesture start state.
|
||||||
|
// This keeps WKWebView's compositor in sync with only 1 DOM write/frame.
|
||||||
|
let gestureBaseZoom: number | null = null;
|
||||||
|
let gestureBaseTranslateX: number | null = null;
|
||||||
|
let gestureBaseTranslateY: number | null = null;
|
||||||
|
let gestureRAF: number | null = null;
|
||||||
|
let lastTransformTime = 0;
|
||||||
|
|
||||||
|
const applyContainerTransform = () => {
|
||||||
|
gestureRAF = null;
|
||||||
|
if (gestureBaseZoom === null) return;
|
||||||
|
const { zoom, translateX, translateY } = this.viewport;
|
||||||
|
const relativeScale = zoom / gestureBaseZoom;
|
||||||
|
const isPureTranslate = Math.abs(relativeScale - 1) < 1e-3;
|
||||||
|
const now = performance.now();
|
||||||
|
// Scale gestures were already throttled here. The new evidence shows the
|
||||||
|
// crash can still happen while all editor/scroll counters stay at zero,
|
||||||
|
// which points back to this gesture-time container transform path.
|
||||||
|
// On iOS at far-out zoom (the 0.4 repro band), even pure translate can
|
||||||
|
// still move a very large layer tree (17 canvases + active blocks). So
|
||||||
|
// we now also throttle pure-translate writes in that zoom band instead of
|
||||||
|
// assuming they are always cheap.
|
||||||
|
if (
|
||||||
|
shouldSkipGestureTransformWrite({
|
||||||
|
isPureTranslate,
|
||||||
|
zoom,
|
||||||
|
elapsedMs: now - lastTransformTime,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
gestureRAF = requestAnimationFrame(applyContainerTransform);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTransformTime = now;
|
||||||
|
// Container transform: scale changes block sizes, translate compensates
|
||||||
|
// for the center shift. Formula: final_pos = container_translate + scale * base_pos
|
||||||
|
// We need: container_translate + scale * base_pos = current_pos
|
||||||
|
// => container_translate = current_translate - scale * base_translate
|
||||||
|
const dx = translateX - relativeScale * gestureBaseTranslateX!;
|
||||||
|
const dy = translateY - relativeScale * gestureBaseTranslateY!;
|
||||||
|
// Pure pan (relativeScale === 1) is the common gesture and the one that
|
||||||
|
// crashes WKWebView's compositor: a transform that carries scale() keeps
|
||||||
|
// the layer on the "non-trivial transform" path, so WebKit re-rasterizes
|
||||||
|
// the whole container — and with OVERSCAN_RATIO that canvas area is
|
||||||
|
// roughly 2x the visible area behind many canvas layers, which overruns
|
||||||
|
// the GPU compositor (rafGap spikes while drift stays low). Emitting a bare
|
||||||
|
// translate() instead routes panning through the cheap layer-move fast
|
||||||
|
// path with no re-rasterization. The math is identical when scale === 1
|
||||||
|
// (dx/dy already reduce to the pan delta), so this is exact, not a
|
||||||
|
// visual approximation. scale() is only emitted for actual zoom.
|
||||||
|
this.style.transform = isPureTranslate
|
||||||
|
? `translate(${dx}px, ${dy}px)`
|
||||||
|
: `translate(${dx}px, ${dy}px) scale(${relativeScale})`;
|
||||||
|
this.style.transformOrigin = '0 0';
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleContainerTransform = () => {
|
||||||
|
if (gestureRAF === null) {
|
||||||
|
gestureRAF = requestAnimationFrame(applyContainerTransform);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startGestureTransform = () => {
|
||||||
|
gestureBaseZoom = this.viewport.zoom;
|
||||||
|
gestureBaseTranslateX = this.viewport.translateX;
|
||||||
|
gestureBaseTranslateY = this.viewport.translateY;
|
||||||
|
// Let the first frame of a new gesture apply immediately.
|
||||||
|
lastTransformTime = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearContainerTransform = () => {
|
||||||
|
if (gestureRAF !== null) {
|
||||||
|
cancelAnimationFrame(gestureRAF);
|
||||||
|
gestureRAF = null;
|
||||||
|
}
|
||||||
|
gestureBaseZoom = null;
|
||||||
|
gestureBaseTranslateX = null;
|
||||||
|
gestureBaseTranslateY = null;
|
||||||
|
this.style.transform = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- End-of-gesture recovery ---
|
||||||
|
const cancelPendingRefresh = () => {
|
||||||
|
if (pendingTimerId !== null) {
|
||||||
|
clearTimeout(pendingTimerId);
|
||||||
|
pendingTimerId = null;
|
||||||
|
}
|
||||||
|
if (cancelChunked !== null) {
|
||||||
|
cancelChunked();
|
||||||
|
cancelChunked = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleIdleRefresh = () => {
|
||||||
|
cancelPendingRefresh();
|
||||||
|
const delayMs = getPostGestureRecoveryDelay({
|
||||||
|
isPanning: this.viewport.panning$.value,
|
||||||
|
isZooming: this.viewport.zooming$.value,
|
||||||
|
fallbackDelayMs: viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY,
|
||||||
|
});
|
||||||
|
pendingTimerId = setTimeout(() => {
|
||||||
|
pendingTimerId = null;
|
||||||
|
// If a gesture is still in-flight when the timer fires (e.g. inertial
|
||||||
|
// scroll or clamped setZoom at the zoom floor keeps re-arming the
|
||||||
|
// panning$/zooming$ debounce), do NOT drop the refresh — reschedule
|
||||||
|
// it. Dropping here is what left connectors/elements blank until the
|
||||||
|
// user tapped to force a synchronous refresh.
|
||||||
|
if (this.viewport.panning$.value || this.viewport.zooming$.value) {
|
||||||
|
scheduleIdleRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Remove container transform before per-block update
|
||||||
|
clearContainerTransform();
|
||||||
|
this._lastViewportRefreshTime = performance.now();
|
||||||
|
// Use chunked activation to spread block rendering across frames
|
||||||
|
cancelChunked = this._chunkedHideOutsideAndNoSelectedBlock(() => {
|
||||||
|
cancelChunked = null;
|
||||||
|
// After all blocks are activated, emit viewportUpdated
|
||||||
|
// to update individual block transforms
|
||||||
|
this.viewport.viewportUpdated.next({
|
||||||
|
zoom: this.viewport.zoom,
|
||||||
|
center: [this.viewport.centerX, this.viewport.centerY],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen to panning$ to drive the container transform during gestures
|
||||||
|
// and handle end-of-gesture recovery
|
||||||
|
this.disposables.add(
|
||||||
|
this.viewport.panning$.subscribe(panning => {
|
||||||
|
if (panning) {
|
||||||
|
if (gestureBaseZoom === null) {
|
||||||
|
startGestureTransform();
|
||||||
|
}
|
||||||
|
scheduleContainerTransform();
|
||||||
|
cancelPendingRefresh();
|
||||||
|
} else {
|
||||||
|
scheduleIdleRefresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.disposables.add(
|
||||||
|
this.viewport.zooming$.subscribe(zooming => {
|
||||||
|
if (zooming) {
|
||||||
|
if (gestureBaseZoom === null) {
|
||||||
|
startGestureTransform();
|
||||||
|
}
|
||||||
|
scheduleContainerTransform();
|
||||||
|
cancelPendingRefresh();
|
||||||
|
} else {
|
||||||
|
scheduleIdleRefresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.disposables.add({
|
||||||
|
dispose: () => {
|
||||||
|
cancelPendingRefresh();
|
||||||
|
clearContainerTransform();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
this._clearPendingViewportRefreshTimer();
|
this._clearPendingViewportRefreshTimer();
|
||||||
|
this._cancelPendingChunkedHide();
|
||||||
|
this._restoreParkedBlockViews();
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,120 @@ export const ZOOM_INITIAL = 1.0;
|
|||||||
|
|
||||||
export const FIT_TO_SCREEN_PADDING = 100;
|
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 {
|
export interface ViewportRecord {
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -92,6 +206,13 @@ export class Viewport {
|
|||||||
top: number;
|
top: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
resizeStarted = new Subject<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
viewportMoved = new Subject<IVec>();
|
viewportMoved = new Subject<IVec>();
|
||||||
|
|
||||||
viewportUpdated = new Subject<{
|
viewportUpdated = new Subject<{
|
||||||
@@ -99,12 +220,71 @@ export class Viewport {
|
|||||||
center: IVec;
|
center: IVec;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
zoomUpdated = new Subject<{
|
||||||
|
previousZoom: number;
|
||||||
|
zoom: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
zooming$ = new BehaviorSubject<boolean>(false);
|
zooming$ = new BehaviorSubject<boolean>(false);
|
||||||
panning$ = new BehaviorSubject<boolean>(false);
|
panning$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
ZOOM_MAX = ZOOM_MAX;
|
/**
|
||||||
|
* Per-instance override for the maximum zoom. When unset, the value is read
|
||||||
|
* dynamically from {@link viewportRuntimeConfig} so that runtime overrides
|
||||||
|
* (e.g. iOS mobile-safe limits configured at app startup) always apply,
|
||||||
|
* regardless of whether this instance was constructed before or after the
|
||||||
|
* override ran.
|
||||||
|
*/
|
||||||
|
private _zoomMaxOverride?: number;
|
||||||
|
|
||||||
ZOOM_MIN = ZOOM_MIN;
|
private _zoomMinOverride?: number;
|
||||||
|
|
||||||
|
get ZOOM_MAX() {
|
||||||
|
return this._zoomMaxOverride ?? viewportRuntimeConfig.ZOOM_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
set ZOOM_MAX(value: number) {
|
||||||
|
this._zoomMaxOverride = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ZOOM_MIN() {
|
||||||
|
return this._zoomMinOverride ?? viewportRuntimeConfig.ZOOM_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
set ZOOM_MIN(value: number) {
|
||||||
|
this._zoomMinOverride = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum pixel movement before triggering a viewport refresh during panning.
|
||||||
|
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
|
||||||
|
* Default: 18 (desktop-optimized).
|
||||||
|
*/
|
||||||
|
VIEWPORT_REFRESH_PIXEL_THRESHOLD =
|
||||||
|
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum interval (ms) between viewport refreshes during continuous interaction.
|
||||||
|
* Higher values reduce refresh frequency, lowering memory pressure on mobile.
|
||||||
|
* Default: 120 (desktop-optimized).
|
||||||
|
*/
|
||||||
|
VIEWPORT_REFRESH_MAX_INTERVAL =
|
||||||
|
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, viewport element visibility refreshes are skipped entirely during
|
||||||
|
* panning/zooming, deferring all DOM mutations until the gesture ends.
|
||||||
|
* Prevents JS main thread blocking that can cause WKWebView process termination.
|
||||||
|
* Default: false (desktop behavior unchanged).
|
||||||
|
*/
|
||||||
|
SKIP_REFRESH_DURING_GESTURE =
|
||||||
|
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE;
|
||||||
|
|
||||||
|
LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT =
|
||||||
|
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT;
|
||||||
|
|
||||||
|
LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO =
|
||||||
|
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_DISTANCE_RATIO;
|
||||||
|
|
||||||
private readonly _resetZooming = debounce(() => {
|
private readonly _resetZooming = debounce(() => {
|
||||||
this.zooming$.next(false);
|
this.zooming$.next(false);
|
||||||
@@ -144,7 +324,7 @@ export class Viewport {
|
|||||||
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
|
const newCenterX = initialTopLeftX + width / (2 * this.zoom);
|
||||||
const newCenterY = initialTopLeftY + height / (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._width = width;
|
||||||
this._height = height;
|
this._height = height;
|
||||||
this._left = left;
|
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() {
|
get viewportMaxXY() {
|
||||||
const { centerX, centerY, width, height, zoom } = this;
|
const { centerX, centerY, width, height, zoom } = this;
|
||||||
return {
|
return {
|
||||||
@@ -297,8 +520,10 @@ export class Viewport {
|
|||||||
dispose() {
|
dispose() {
|
||||||
this.clearViewportElement();
|
this.clearViewportElement();
|
||||||
this.sizeUpdated.complete();
|
this.sizeUpdated.complete();
|
||||||
|
this.resizeStarted.complete();
|
||||||
this.viewportMoved.complete();
|
this.viewportMoved.complete();
|
||||||
this.viewportUpdated.complete();
|
this.viewportUpdated.complete();
|
||||||
|
this.zoomUpdated.complete();
|
||||||
this._resizeSubject.complete();
|
this._resizeSubject.complete();
|
||||||
this.zooming$.complete();
|
this.zooming$.complete();
|
||||||
this.panning$.complete();
|
this.panning$.complete();
|
||||||
@@ -307,7 +532,7 @@ export class Viewport {
|
|||||||
getFitToScreenData(
|
getFitToScreenData(
|
||||||
bounds?: Bound | null,
|
bounds?: Bound | null,
|
||||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||||
maxZoom = ZOOM_MAX,
|
maxZoom = this.ZOOM_MAX,
|
||||||
fitToScreenPadding = 100
|
fitToScreenPadding = 100
|
||||||
) {
|
) {
|
||||||
let { centerX, centerY, zoom } = this;
|
let { centerX, centerY, zoom } = this;
|
||||||
@@ -324,7 +549,11 @@ export class Viewport {
|
|||||||
(width - fitToScreenPadding - (pr + pl)) / w,
|
(width - fitToScreenPadding - (pr + pl)) / w,
|
||||||
(height - fitToScreenPadding - (pt + pb)) / h
|
(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;
|
centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2;
|
||||||
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
|
centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2;
|
||||||
@@ -353,6 +582,12 @@ export class Viewport {
|
|||||||
|
|
||||||
this._left = left;
|
this._left = left;
|
||||||
this._top = top;
|
this._top = top;
|
||||||
|
this.resizeStarted.next({
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
this._resizeSubject.next({
|
this._resizeSubject.next({
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
@@ -367,20 +602,40 @@ export class Viewport {
|
|||||||
* @param centerY The new y coordinate of the center of the 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.
|
* @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) {
|
if (forceUpdate && this._isResizing) {
|
||||||
this._forceCompleteResize();
|
this._forceCompleteResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
this._center.x = centerX;
|
this._center.x = centerX;
|
||||||
this._center.y = centerY;
|
this._center.y = centerY;
|
||||||
|
|
||||||
|
const gestureActive = this.panning$.value || this.zooming$.value;
|
||||||
|
|
||||||
|
if (signalPanning) {
|
||||||
this.panning$.next(true);
|
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({
|
this.viewportUpdated.next({
|
||||||
zoom: this.zoom,
|
zoom: this.zoom,
|
||||||
center: Vec.toVec(this.center) as IVec,
|
center: Vec.toVec(this.center) as IVec,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (signalPanning) {
|
||||||
this._resetPanning();
|
this._resetPanning();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setRect(left: number, top: number, width: number, height: number) {
|
setRect(left: number, top: number, width: number, height: number) {
|
||||||
if (this._isResizing) {
|
if (this._isResizing) {
|
||||||
@@ -410,7 +665,8 @@ export class Viewport {
|
|||||||
newZoom: number,
|
newZoom: number,
|
||||||
newCenter = Vec.toVec(this.center),
|
newCenter = Vec.toVec(this.center),
|
||||||
smooth = false,
|
smooth = false,
|
||||||
forceUpdate = true
|
forceUpdate = true,
|
||||||
|
signalGesture = false
|
||||||
) {
|
) {
|
||||||
// Force complete any pending resize operations if forceUpdate is true
|
// Force complete any pending resize operations if forceUpdate is true
|
||||||
if (forceUpdate && this._isResizing) {
|
if (forceUpdate && this._isResizing) {
|
||||||
@@ -421,19 +677,19 @@ export class Viewport {
|
|||||||
if (smooth) {
|
if (smooth) {
|
||||||
const cofficient = preZoom / newZoom;
|
const cofficient = preZoom / newZoom;
|
||||||
if (cofficient === 1) {
|
if (cofficient === 1) {
|
||||||
this.smoothTranslate(newCenter[0], newCenter[1]);
|
this.smoothTranslate(newCenter[0], newCenter[1], 10, signalGesture);
|
||||||
} else {
|
} else {
|
||||||
const center = [this.centerX, this.centerY] as IVec;
|
const center = [this.centerX, this.centerY] as IVec;
|
||||||
const focusPoint = Vec.mul(
|
const focusPoint = Vec.mul(
|
||||||
Vec.sub(newCenter, Vec.mul(center, cofficient)),
|
Vec.sub(newCenter, Vec.mul(center, cofficient)),
|
||||||
1 / (1 - cofficient)
|
1 / (1 - cofficient)
|
||||||
);
|
);
|
||||||
this.smoothZoom(newZoom, Vec.toPoint(focusPoint));
|
this.smoothZoom(newZoom, Vec.toPoint(focusPoint), 10, signalGesture);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._center.x = newCenter[0];
|
this._center.x = newCenter[0];
|
||||||
this._center.y = newCenter[1];
|
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,
|
bound: Bound,
|
||||||
padding: [number, number, number, number] = [0, 0, 0, 0],
|
padding: [number, number, number, number] = [0, 0, 0, 0],
|
||||||
smooth = false,
|
smooth = false,
|
||||||
forceUpdate = true
|
forceUpdate = true,
|
||||||
|
signalGesture = false
|
||||||
) {
|
) {
|
||||||
let [pt, pr, pb, pl] = padding;
|
let [pt, pr, pb, pl] = padding;
|
||||||
|
|
||||||
@@ -485,7 +742,7 @@ export class Viewport {
|
|||||||
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
|
bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2,
|
||||||
] as IVec;
|
] 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 */
|
/** 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.
|
* Set the viewport to the new zoom.
|
||||||
* @param zoom The new zoom value.
|
* @param zoom The new zoom value.
|
||||||
* @param focusPoint The point to focus on after zooming, default is the center of the viewport.
|
* @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.
|
* @param forceUpdate Whether to force complete any pending resize operations before setting the viewport.
|
||||||
*/
|
*/
|
||||||
setZoom(
|
setZoom(
|
||||||
zoom: number,
|
zoom: number,
|
||||||
focusPoint?: IPoint,
|
focusPoint?: IPoint,
|
||||||
wheel = false,
|
_wheel = false,
|
||||||
forceUpdate = true
|
forceUpdate = true,
|
||||||
|
signalGesture = false
|
||||||
) {
|
) {
|
||||||
if (forceUpdate && this._isResizing) {
|
if (forceUpdate && this._isResizing) {
|
||||||
this._forceCompleteResize();
|
this._forceCompleteResize();
|
||||||
@@ -532,18 +790,21 @@ export class Viewport {
|
|||||||
Vec.toVec(focusPoint),
|
Vec.toVec(focusPoint),
|
||||||
Vec.mul(offset, prevZoom / newZoom)
|
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.zooming$.next(true);
|
||||||
}
|
}
|
||||||
this.setCenter(newCenter[0], newCenter[1], forceUpdate);
|
this.setCenter(newCenter[0], newCenter[1], forceUpdate, signalGesture);
|
||||||
this.viewportUpdated.next({
|
this.zoomUpdated.next({ previousZoom: prevZoom, zoom: newZoom });
|
||||||
zoom: this.zoom,
|
// setCenter already emits viewportUpdated, no need to emit again here.
|
||||||
center: Vec.toVec(this.center) as IVec,
|
if (signalGesture) {
|
||||||
});
|
|
||||||
this._resetZooming();
|
this._resetZooming();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
smoothTranslate(x: number, y: number, numSteps = 10) {
|
smoothTranslate(x: number, y: number, numSteps = 10, signalGesture = false) {
|
||||||
const { center } = this;
|
const { center } = this;
|
||||||
const delta = { x: x - center.x, y: y - center.y };
|
const delta = { x: x - center.x, y: y - center.y };
|
||||||
const innerSmoothTranslate = () => {
|
const innerSmoothTranslate = () => {
|
||||||
@@ -558,7 +819,7 @@ export class Viewport {
|
|||||||
const signY = delta.y > 0 ? 1 : -1;
|
const signY = delta.y > 0 ? 1 : -1;
|
||||||
nextCenter.x = cutoff(nextCenter.x, x, signX);
|
nextCenter.x = cutoff(nextCenter.x, x, signX);
|
||||||
nextCenter.y = cutoff(nextCenter.y, y, signY);
|
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();
|
if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate();
|
||||||
});
|
});
|
||||||
@@ -566,7 +827,12 @@ export class Viewport {
|
|||||||
innerSmoothTranslate();
|
innerSmoothTranslate();
|
||||||
}
|
}
|
||||||
|
|
||||||
smoothZoom(zoom: number, focusPoint?: IPoint, numSteps = 10) {
|
smoothZoom(
|
||||||
|
zoom: number,
|
||||||
|
focusPoint?: IPoint,
|
||||||
|
numSteps = 10,
|
||||||
|
signalGesture = false
|
||||||
|
) {
|
||||||
const delta = zoom - this.zoom;
|
const delta = zoom - this.zoom;
|
||||||
if (this._rafId) cancelAnimationFrame(this._rafId);
|
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||||
|
|
||||||
@@ -576,7 +842,7 @@ export class Viewport {
|
|||||||
const step = delta / numSteps;
|
const step = delta / numSteps;
|
||||||
const nextZoom = cutoff(this.zoom + step, zoom, sign);
|
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();
|
if (nextZoom != zoom) innerSmoothZoom();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ function updateBlockVisibility(view: GfxBlockComponent) {
|
|||||||
if (view.transformState$.value === 'active') {
|
if (view.transformState$.value === 'active') {
|
||||||
view.style.visibility = 'visible';
|
view.style.visibility = 'visible';
|
||||||
view.style.pointerEvents = 'auto';
|
view.style.pointerEvents = 'auto';
|
||||||
view.classList.remove('block-idle');
|
view.classList.remove('block-idle', 'block-survival');
|
||||||
view.classList.add('block-active');
|
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 {
|
} else {
|
||||||
view.style.visibility = 'hidden';
|
view.style.visibility = 'hidden';
|
||||||
view.style.pointerEvents = 'none';
|
view.style.pointerEvents = 'none';
|
||||||
view.classList.remove('block-active');
|
view.classList.remove('block-active', 'block-survival');
|
||||||
view.classList.add('block-idle');
|
view.classList.add('block-idle');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,8 +60,19 @@ function updateBlockVisibility(view: GfxBlockComponent) {
|
|||||||
function handleGfxConnection(instance: GfxBlockComponent) {
|
function handleGfxConnection(instance: GfxBlockComponent) {
|
||||||
instance.style.position = 'absolute';
|
instance.style.position = 'absolute';
|
||||||
|
|
||||||
|
const viewport = instance.gfx.viewport;
|
||||||
|
|
||||||
instance.disposables.add(
|
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);
|
updateTransform(instance);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -95,7 +111,7 @@ export abstract class GfxBlockComponent<
|
|||||||
{
|
{
|
||||||
[GfxElementSymbol] = true;
|
[GfxElementSymbol] = true;
|
||||||
|
|
||||||
readonly transformState$ = signal<'idle' | 'active'>('active');
|
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
|
||||||
|
|
||||||
get gfx() {
|
get gfx() {
|
||||||
return this.std.get(GfxControllerIdentifier);
|
return this.std.get(GfxControllerIdentifier);
|
||||||
@@ -207,7 +223,7 @@ export function toGfxBlockComponent<
|
|||||||
return class extends CustomBlock {
|
return class extends CustomBlock {
|
||||||
[GfxElementSymbol] = true;
|
[GfxElementSymbol] = true;
|
||||||
|
|
||||||
readonly transformState$ = signal<'idle' | 'active'>('active');
|
readonly transformState$ = signal<'idle' | 'survival' | 'active'>('active');
|
||||||
|
|
||||||
override selected$ = computed(() => {
|
override selected$ = computed(() => {
|
||||||
const selection = this.std.selection.value.find(
|
const selection = this.std.selection.value.find(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import type { Memento } from '@toeverything/infra';
|
import type { Memento } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +116,9 @@ export class PersistentJSONFileStorage implements Memento {
|
|||||||
exhaustMapWithTrailing(() => {
|
exhaustMapWithTrailing(() => {
|
||||||
return fromPromise(async () => {
|
return fromPromise(async () => {
|
||||||
try {
|
try {
|
||||||
|
await fs.promises.mkdir(path.dirname(this.filepath), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
await fs.promises.writeFile(
|
await fs.promises.writeFile(
|
||||||
this.filepath,
|
this.filepath,
|
||||||
JSON.stringify(this.data, null, 2),
|
JSON.stringify(this.data, null, 2),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -88,8 +88,6 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Plugins;
|
path = Plugins;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -309,9 +307,13 @@
|
|||||||
);
|
);
|
||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||||
@@ -504,7 +506,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
@@ -517,7 +519,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.26.3;
|
MARKETING_VERSION = 0.26.5;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -540,7 +542,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 12;
|
CURRENT_PROJECT_VERSION = 17;
|
||||||
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
DEVELOPMENT_TEAM = 73YMMDVT2M;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
@@ -553,7 +555,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.26.3;
|
MARKETING_VERSION = 0.26.5;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import Intelligents
|
import Intelligents
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
class AFFiNEViewController: CAPBridgeViewController {
|
class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate {
|
||||||
var intelligentsButton: IntelligentsButton?
|
var intelligentsButton: IntelligentsButton?
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
webView?.allowsBackForwardNavigationGestures = true
|
webView?.allowsBackForwardNavigationGestures = false
|
||||||
navigationController?.navigationBar.isHidden = true
|
navigationController?.navigationBar.isHidden = true
|
||||||
extendedLayoutIncludesOpaqueBars = false
|
extendedLayoutIncludesOpaqueBars = false
|
||||||
edgesForExtendedLayout = []
|
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()
|
let intelligentsButton = installIntelligentsButton()
|
||||||
intelligentsButton.delegate = self
|
intelligentsButton.delegate = self
|
||||||
self.intelligentsButton = intelligentsButton
|
self.intelligentsButton = intelligentsButton
|
||||||
dismissIntelligentsButton()
|
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() {
|
override func capacitorDidLoad() {
|
||||||
let plugins: [CAPPlugin] = [
|
let plugins: [CAPPlugin] = [
|
||||||
AuthPlugin(),
|
AuthPlugin(),
|
||||||
@@ -56,7 +80,7 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
private func checkEligibilityOfIntelligent() {
|
private func checkEligibilityOfIntelligent() {
|
||||||
guard !isCheckingIntelligentEligibility else { return }
|
guard !isCheckingIntelligentEligibility else { return }
|
||||||
assert(intelligentsButton != nil)
|
assert(intelligentsButton != nil)
|
||||||
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
|
guard intelligentsButton?.isHidden ?? false else { return }
|
||||||
isCheckingIntelligentEligibility = true
|
isCheckingIntelligentEligibility = true
|
||||||
IntelligentContext.shared.webView = webView
|
IntelligentContext.shared.webView = webView
|
||||||
IntelligentContext.shared.preparePresent { [self] result in
|
IntelligentContext.shared.preparePresent { [self] result in
|
||||||
@@ -75,4 +99,31 @@ class AFFiNEViewController: CAPBridgeViewController {
|
|||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
intelligentsButtonTimer?.invalidate()
|
intelligentsButtonTimer?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
scrollView.zoomScale = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
if scrollView.contentOffset != .zero {
|
||||||
|
scrollView.contentOffset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Web Content Process Crash Recovery
|
||||||
|
|
||||||
|
// NOTE: Capacitor's CAPBridgeViewController owns the WKWebView
|
||||||
|
// navigationDelegate (it assigns its own WebViewDelegationHandler), so this
|
||||||
|
// override is NOT called in practice — Capacitor's handler logs
|
||||||
|
// "⚡️ WebView process terminated" and reloads instead. Kept as defensive
|
||||||
|
// fallback, matching the prior baseline behavior.
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
webView.reload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>10</string>
|
<string>17</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
<string>AFFiNE requires access to select photos from your photo library and insert them into your documents</string>
|
||||||
<key>NSUserTrackingUsageDescription</key>
|
<key>NSUserTrackingUsageDescription</key>
|
||||||
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
<string>Rest assured, enabling this permission won't access your private info on other sites. It's only used to identify your device and improve security and product experience.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIImageName</key>
|
<key>UIImageName</key>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -171,16 +171,16 @@ private extension ChatManager {
|
|||||||
let uploadableAttachments: [CopilotAttachmentUpload] = [
|
let uploadableAttachments: [CopilotAttachmentUpload] = [
|
||||||
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
|
editorData.fileAttachments.map { file -> CopilotAttachmentUpload in
|
||||||
.init(
|
.init(
|
||||||
originalName: file.name,
|
data: file.data ?? .init(),
|
||||||
mimeType: mimeType(text: file.name),
|
mimeType: mimeType(text: file.name),
|
||||||
data: file.data ?? .init()
|
originalName: file.name
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
|
editorData.imageAttachments.map { image -> CopilotAttachmentUpload in
|
||||||
.init(
|
.init(
|
||||||
originalName: "image.jpg",
|
data: image.imageData,
|
||||||
mimeType: mimeType(pathExtension: "jpg"),
|
mimeType: mimeType(pathExtension: "jpg"),
|
||||||
data: image.imageData
|
originalName: "image.jpg"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
].flatMap(\.self)
|
].flatMap(\.self)
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: a5bf59e09f9dd82694fdcca4d107b4d215ac470f
|
Capacitor: 12914e6f1b7835e161a74ebd19cb361efa37a7dd
|
||||||
CapacitorApp: 3ddbd30ac18c321531c3da5e707b60873d89dd60
|
CapacitorApp: 63b237168fc869e758481dba283315a85743ee78
|
||||||
CapacitorBrowser: 66aa8ff09cdca2a327ce464b113b470e6f667753
|
CapacitorBrowser: b98aa3db018a2ce4c68242d27e596c344f3b81b3
|
||||||
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
|
CapacitorCordova: 31bbe4466000c6b86d9b7f1181ee286cff0205aa
|
||||||
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
|
CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc
|
||||||
CapacitorKeyboard: 45cae3956a6f4fb1753f9a4df3e884aeaed8fe82
|
CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3
|
||||||
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
|
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
|
||||||
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
|
||||||
|
|
||||||
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const config: CapacitorConfig & AppConfig = {
|
|||||||
scheme: 'AFFiNE',
|
scheme: 'AFFiNE',
|
||||||
path: '.',
|
path: '.',
|
||||||
webContentsDebuggingEnabled: true,
|
webContentsDebuggingEnabled: true,
|
||||||
|
// Silence Capacitor's bridge logging (⚡️ TO JS / ⚡️ To Native -> / ⚡️ [log]).
|
||||||
|
loggingBehavior: 'none',
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// url: 'http://localhost:8080',
|
// url: 'http://localhost:8080',
|
||||||
|
|||||||
@@ -1,3 +1,88 @@
|
|||||||
import '@affine/core/bootstrap/browser';
|
import '@affine/core/bootstrap/browser';
|
||||||
import '@affine/core/bootstrap/cleanup';
|
import '@affine/core/bootstrap/cleanup';
|
||||||
import './proxy';
|
import './proxy';
|
||||||
|
|
||||||
|
import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx';
|
||||||
|
|
||||||
|
// iOS WKWebView terminates the web content process when edgeless compositing
|
||||||
|
// memory (GPU-side IOSurface tiles) spikes. Two distinct triggers exist:
|
||||||
|
// 1. Resting canvas pixel memory — bounded by CANVAS_DPR_CAP_BY_ZOOM below.
|
||||||
|
// 2. The transient GPU/DOM churn of a *fast* gesture at extreme zoom-out,
|
||||||
|
// where the whole document composites at once.
|
||||||
|
//
|
||||||
|
// These overrides are applied once at module load, before any editor or
|
||||||
|
// readonly preview mounts, so every Viewport instance is constructed with the
|
||||||
|
// mobile-safe limits. Setting them at construction (rather than mutating a live
|
||||||
|
// Viewport afterward) avoids both the race condition and the wrong-instance
|
||||||
|
// problem that previously left the preview viewport on desktop defaults.
|
||||||
|
//
|
||||||
|
// Strategy (multi-layer, stability first):
|
||||||
|
// - The dpr cap (below) is the real memory lever: canvas backing-store memory
|
||||||
|
// scales with dpr^2, so forcing dpr 1 across the zoom-out range is what
|
||||||
|
// keeps the compositing budget bounded and stops the web process crashing.
|
||||||
|
// - ZOOM_MIN 0.4 bounds how small content can get (and keeps the live zoom in
|
||||||
|
// the dpr-1 bucket); it is a guardrail, not the primary crash fix.
|
||||||
|
// - OVERSCAN_RATIO pre-rasterizes a margin around the visible area on the
|
||||||
|
// *canvas* path, so a pan/zoom moves into content that is *already* painted
|
||||||
|
// instead of blanking out and waiting for the post-gesture refresh. This is
|
||||||
|
// what fixes "connectors/elements vanish for 1-2s". Canvas overscan grows
|
||||||
|
// backing-store area, so keep it modest and rely on the dpr cap to bound
|
||||||
|
// mobile memory.
|
||||||
|
// - OVERSCAN_RATIO_BLOCK is the *separate* knob for DOM block mounting, which
|
||||||
|
// is expensive: each mounted block is its own composited layer subtree, so
|
||||||
|
// enlarging this multiplies resident memory and is what drives the iOS
|
||||||
|
// jetsam kill. On-device diagnostics showed the active-block count doubling
|
||||||
|
// (~16 → ~32) right before each crash when block mounting shared the wide
|
||||||
|
// canvas margin. Kept at 0 so blocks mount on the exact visible bound while
|
||||||
|
// connectors still pre-paint via the wider canvas margin above.
|
||||||
|
viewportRuntimeConfig.ZOOM_MIN = 0.4;
|
||||||
|
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60;
|
||||||
|
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300;
|
||||||
|
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true;
|
||||||
|
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
|
||||||
|
|
||||||
|
// Pre-paint a 20% margin on every side of the viewport for the *canvas* render
|
||||||
|
// path. This keeps nearby connectors/shapes warm during orientation and zoom
|
||||||
|
// gestures, but trims the backing-store and paint budget versus the previous
|
||||||
|
// 35% setting so low-zoom survival mode has less work to recover from on iOS.
|
||||||
|
viewportRuntimeConfig.OVERSCAN_RATIO = 0.2;
|
||||||
|
|
||||||
|
// Keep DOM block mounting on the exact visible bound (no overscan). Each mounted
|
||||||
|
// block adds a composited layer subtree to the WebContent process; widening this
|
||||||
|
// is what doubled the active-block count (~16 → ~32) and triggered the iOS
|
||||||
|
// jetsam memory kill in on-device logs. Connectors/elements still pre-paint via
|
||||||
|
// the generous canvas OVERSCAN_RATIO above, so visibility is preserved without
|
||||||
|
// paying the block-mounting memory cost.
|
||||||
|
viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0;
|
||||||
|
|
||||||
|
// After a gesture ends, blocks and canvases are repainted once. The default
|
||||||
|
// 800ms felt sluggish on device (elements/connectors took ~3-4s to settle once
|
||||||
|
// the trailing 200ms panning/zooming debounce and rescheduling were factored
|
||||||
|
// in). Shorten the post-gesture refresh so the total settle time — ~200ms
|
||||||
|
// debounce + this delay — lands under ~500ms. Both the canvas and block timers
|
||||||
|
// read this same value, so connectors and elements reappear together instead of
|
||||||
|
// staggered.
|
||||||
|
viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220;
|
||||||
|
|
||||||
|
// At far-out zoom each block is tiny on screen, so a full retina backing store
|
||||||
|
// (width * devicePixelRatio) is wasted pixels — and on iOS that waste is what
|
||||||
|
// pushes WKWebView's compositing budget over the edge and crashes the web
|
||||||
|
// content process during pan. Cap the canvas backing-store dpr the further out
|
||||||
|
// we zoom: the smaller the content, the less resolution it needs.
|
||||||
|
//
|
||||||
|
// Canvas memory scales with backing-store area and dpr^2. With a fixed viewport
|
||||||
|
// and overscan ratio, the dpr bucket dominates: on-device diagnostics showed
|
||||||
|
// zoom 0.4 with dpr 2 hitting ~7.2 mp and crashing on the first fast zoom-out;
|
||||||
|
// the same scene at dpr 1 sits near ~1.8 mp and is stable. Raising ZOOM_MIN
|
||||||
|
// alone cannot fix it — it only moves the zoom between buckets.
|
||||||
|
//
|
||||||
|
// Therefore force dpr 1 for the entire mobile zoom-out range (zoom < 0.5, which
|
||||||
|
// covers the 0.4 floor) to keep the compositing budget bounded. Connectors are
|
||||||
|
// slightly thinner at the floor as a result; stability is the hard requirement
|
||||||
|
// here, so that trade is accepted. dpr 2 is kept for the near-1.0 range where
|
||||||
|
// content is large and crispness matters. Buckets are checked low-to-high; the
|
||||||
|
// first matching `zoom < threshold` wins.
|
||||||
|
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
|
||||||
|
[0.5, 1],
|
||||||
|
[0.8, 2],
|
||||||
|
];
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import type { AppTabLink } from './type';
|
|||||||
export const AppTabs = ({
|
export const AppTabs = ({
|
||||||
background,
|
background,
|
||||||
fixed = true,
|
fixed = true,
|
||||||
|
hidden = false,
|
||||||
}: {
|
}: {
|
||||||
background?: string;
|
background?: string;
|
||||||
fixed?: boolean;
|
fixed?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const virtualKeyboardService = useService(VirtualKeyboardService);
|
const virtualKeyboardService = useService(VirtualKeyboardService);
|
||||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
|
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
|
||||||
@@ -47,7 +49,8 @@ export const AppTabs = ({
|
|||||||
...assignInlineVars({
|
...assignInlineVars({
|
||||||
[styles.appTabsBackground]: background,
|
[styles.appTabsBackground]: background,
|
||||||
}),
|
}),
|
||||||
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
|
visibility: hidden || virtualKeyboardVisible ? 'hidden' : 'visible',
|
||||||
|
pointerEvents: hidden || virtualKeyboardVisible ? 'none' : 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ul className={styles.appTabsInner} role="tablist">
|
<ul className={styles.appTabsInner} role="tablist">
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export const root = style({
|
|||||||
minHeight: '100dvh',
|
minHeight: '100dvh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
selectors: {
|
||||||
|
'&:has([data-mode="edgeless"])': {
|
||||||
|
height: '100dvh',
|
||||||
|
maxHeight: '100dvh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const header = style({
|
export const header = style({
|
||||||
@@ -76,6 +83,10 @@ export const affineDocViewport = style({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
containerType: 'normal',
|
||||||
|
overflow: 'hidden',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -122,3 +133,27 @@ export const journalIconButton = style({
|
|||||||
export const journalDatePicker = style({
|
export const journalDatePicker = style({
|
||||||
background: cssVarV2('layer/background/primary'),
|
background: cssVarV2('layer/background/primary'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When edgeless mode is active, prevent document-level scrolling
|
||||||
|
// so native scrollView pan gestures don't scroll the page away from the canvas
|
||||||
|
globalStyle('html:has([data-lock-document-scroll="true"])', {
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100dvh',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle('body:has([data-lock-document-scroll="true"])', {
|
||||||
|
height: '100dvh',
|
||||||
|
minHeight: '100dvh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle('body:has([data-lock-document-scroll="true"]):has(>#app-tabs)', {
|
||||||
|
paddingBottom: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent native touch handling on edgeless viewport so canvas handles all gestures
|
||||||
|
globalStyle('[data-mode="edgeless"] .affine-edgeless-viewport', {
|
||||||
|
touchAction: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import * as immersiveModule from './mobile-detail-page.immersive';
|
||||||
|
import {
|
||||||
|
getImmersiveZoomToolbarBottom,
|
||||||
|
isImmersiveTapTarget,
|
||||||
|
isLandscapeWindow,
|
||||||
|
isTapWithinSlop,
|
||||||
|
shouldEnableEdgelessImmersive,
|
||||||
|
shouldLockEdgelessDocumentScroll,
|
||||||
|
shouldShowMobileDetailPageTitle,
|
||||||
|
shouldTrackMobileDetailPageTitleScroll,
|
||||||
|
} from './mobile-detail-page.immersive';
|
||||||
|
|
||||||
|
describe('mobile detail page immersive helpers', () => {
|
||||||
|
test('enables immersive mode only for edgeless landscape', () => {
|
||||||
|
expect(
|
||||||
|
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: true })
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldEnableEdgelessImmersive({ mode: 'page', isLandscape: true })
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: false })
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats window as landscape only when media query and geometry agree', () => {
|
||||||
|
expect(
|
||||||
|
isLandscapeWindow({
|
||||||
|
width: 844,
|
||||||
|
height: 390,
|
||||||
|
matchesLandscape: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLandscapeWindow({
|
||||||
|
width: 390,
|
||||||
|
height: 844,
|
||||||
|
matchesLandscape: true,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLandscapeWindow({
|
||||||
|
width: 844,
|
||||||
|
height: 390,
|
||||||
|
matchesLandscape: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks mismatched orientation signals as unsettled so immersive mode can retry the first rotation sample', () => {
|
||||||
|
expect('getLandscapeWindowMeasurement' in immersiveModule).toBe(true);
|
||||||
|
|
||||||
|
const getLandscapeWindowMeasurement = (
|
||||||
|
immersiveModule as {
|
||||||
|
getLandscapeWindowMeasurement: (params: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
matchesLandscape: boolean;
|
||||||
|
}) => {
|
||||||
|
isLandscape: boolean;
|
||||||
|
settled: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).getLandscapeWindowMeasurement;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getLandscapeWindowMeasurement({
|
||||||
|
width: 844,
|
||||||
|
height: 390,
|
||||||
|
matchesLandscape: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isLandscape: false,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getLandscapeWindowMeasurement({
|
||||||
|
width: 390,
|
||||||
|
height: 844,
|
||||||
|
matchesLandscape: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isLandscape: false,
|
||||||
|
settled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getLandscapeWindowMeasurement({
|
||||||
|
width: 844,
|
||||||
|
height: 390,
|
||||||
|
matchesLandscape: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isLandscape: true,
|
||||||
|
settled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores taps from edgeless toolbar chrome targets', () => {
|
||||||
|
const toolbar = document.createElement('edgeless-toolbar-widget');
|
||||||
|
const toolbarButton = document.createElement('button');
|
||||||
|
toolbar.append(toolbarButton);
|
||||||
|
|
||||||
|
const zoomToolbar = document.createElement('div');
|
||||||
|
zoomToolbar.className = 'edgeless-zoom-toolbar-container';
|
||||||
|
const zoomButton = document.createElement('button');
|
||||||
|
zoomToolbar.append(zoomButton);
|
||||||
|
|
||||||
|
const selectedRect = document.createElement('div');
|
||||||
|
selectedRect.className = 'affine-edgeless-selected-rect';
|
||||||
|
const resizeHandle = document.createElement('div');
|
||||||
|
selectedRect.append(resizeHandle);
|
||||||
|
|
||||||
|
const canvas = document.createElement('div');
|
||||||
|
|
||||||
|
document.body.append(toolbar, zoomToolbar, selectedRect, canvas);
|
||||||
|
|
||||||
|
expect(isImmersiveTapTarget(toolbarButton)).toBe(false);
|
||||||
|
expect(isImmersiveTapTarget(zoomButton)).toBe(false);
|
||||||
|
expect(isImmersiveTapTarget(resizeHandle)).toBe(false);
|
||||||
|
expect(isImmersiveTapTarget(canvas)).toBe(true);
|
||||||
|
expect(isImmersiveTapTarget(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts only small pointer movement as a tap', () => {
|
||||||
|
expect(
|
||||||
|
isTapWithinSlop(
|
||||||
|
{ clientX: 100, clientY: 200 },
|
||||||
|
{ clientX: 104, clientY: 205 }
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isTapWithinSlop(
|
||||||
|
{ clientX: 100, clientY: 200 },
|
||||||
|
{ clientX: 120, clientY: 205 }
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('raises zoom toolbar above tab bar only when immersive chrome is visible', () => {
|
||||||
|
expect(
|
||||||
|
getImmersiveZoomToolbarBottom({
|
||||||
|
immersive: true,
|
||||||
|
chromeVisible: false,
|
||||||
|
})
|
||||||
|
).toBe('10px');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getImmersiveZoomToolbarBottom({
|
||||||
|
immersive: true,
|
||||||
|
chromeVisible: true,
|
||||||
|
tabBarOffset: 'var(--appTabSafeArea)',
|
||||||
|
})
|
||||||
|
).toBe('calc(10px + var(--appTabSafeArea))');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getImmersiveZoomToolbarBottom({
|
||||||
|
immersive: false,
|
||||||
|
chromeVisible: true,
|
||||||
|
tabBarOffset: 'var(--appTabSafeArea)',
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locks document scroll whenever edgeless mode is active', () => {
|
||||||
|
expect(shouldLockEdgelessDocumentScroll('edgeless')).toBe(true);
|
||||||
|
expect(shouldLockEdgelessDocumentScroll('page')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks title scroll only in page mode', () => {
|
||||||
|
expect(shouldTrackMobileDetailPageTitleScroll('page')).toBe(true);
|
||||||
|
expect(shouldTrackMobileDetailPageTitleScroll('edgeless')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows title only after crossing the existing scroll threshold', () => {
|
||||||
|
expect(shouldShowMobileDetailPageTitle(157)).toBe(false);
|
||||||
|
expect(shouldShowMobileDetailPageTitle(158)).toBe(true);
|
||||||
|
expect(shouldShowMobileDetailPageTitle(240)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
export const EDGELESS_IMMERSIVE_TAP_SLOP = 8;
|
||||||
|
|
||||||
|
const IMMERSIVE_TAP_EXCLUDE_SELECTORS = [
|
||||||
|
'edgeless-toolbar-widget',
|
||||||
|
'.edgeless-toolbar-container',
|
||||||
|
'affine-edgeless-zoom-toolbar-widget',
|
||||||
|
'.edgeless-zoom-toolbar-container',
|
||||||
|
'.affine-edgeless-selected-rect',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
export function getLandscapeWindowMeasurement({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
matchesLandscape,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
matchesLandscape: boolean;
|
||||||
|
}) {
|
||||||
|
const geometryLandscape = width > height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLandscape: matchesLandscape && geometryLandscape,
|
||||||
|
settled: matchesLandscape === geometryLandscape,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLandscapeWindow({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
matchesLandscape,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
matchesLandscape: boolean;
|
||||||
|
}) {
|
||||||
|
return getLandscapeWindowMeasurement({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
matchesLandscape,
|
||||||
|
}).isLandscape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldEnableEdgelessImmersive({
|
||||||
|
mode,
|
||||||
|
isLandscape,
|
||||||
|
}: {
|
||||||
|
mode: 'page' | 'edgeless';
|
||||||
|
isLandscape: boolean;
|
||||||
|
}) {
|
||||||
|
return mode === 'edgeless' && isLandscape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldLockEdgelessDocumentScroll(mode: 'page' | 'edgeless') {
|
||||||
|
return mode === 'edgeless';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldTrackMobileDetailPageTitleScroll(
|
||||||
|
mode: 'page' | 'edgeless'
|
||||||
|
) {
|
||||||
|
return mode === 'page';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowMobileDetailPageTitle(scrollY: number) {
|
||||||
|
return scrollY >= 158;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImmersiveTapTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !target.closest(IMMERSIVE_TAP_EXCLUDE_SELECTORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTapWithinSlop(
|
||||||
|
start: { clientX: number; clientY: number },
|
||||||
|
end: { clientX: number; clientY: number },
|
||||||
|
slop = EDGELESS_IMMERSIVE_TAP_SLOP
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
Math.abs(start.clientX - end.clientX) <= slop &&
|
||||||
|
Math.abs(start.clientY - end.clientY) <= slop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImmersiveZoomToolbarBottom({
|
||||||
|
immersive,
|
||||||
|
chromeVisible,
|
||||||
|
tabBarOffset,
|
||||||
|
}: {
|
||||||
|
immersive: boolean;
|
||||||
|
chromeVisible: boolean;
|
||||||
|
tabBarOffset?: string;
|
||||||
|
}) {
|
||||||
|
if (!immersive) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chromeVisible && tabBarOffset) {
|
||||||
|
return `calc(10px + ${tabBarOffset})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '10px';
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-he
|
|||||||
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
||||||
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
|
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
|
||||||
import { PageHeader } from '@affine/core/mobile/components';
|
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 { AIButtonService } from '@affine/core/modules/ai-button';
|
||||||
import { ServerService } from '@affine/core/modules/cloud';
|
import { ServerService } from '@affine/core/modules/cloud';
|
||||||
import { DocService } from '@affine/core/modules/doc';
|
import { DocService } from '@affine/core/modules/doc';
|
||||||
@@ -36,17 +35,44 @@ import {
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
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 { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppTabs } from '../../../components';
|
import { AppTabs } from '../../../components';
|
||||||
|
import { globalVars } from '../../../styles/variables.css';
|
||||||
import { JournalConflictBlock } from './journal-conflict-block';
|
import { JournalConflictBlock } from './journal-conflict-block';
|
||||||
import { JournalDatePicker } from './journal-date-picker';
|
import { JournalDatePicker } from './journal-date-picker';
|
||||||
import * as styles from './mobile-detail-page.css';
|
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 { PageHeaderMenuButton } from './page-header-more-button';
|
||||||
import { PageHeaderShareButton } from './page-header-share-button';
|
import { PageHeaderShareButton } from './page-header-share-button';
|
||||||
|
|
||||||
const DetailPageImpl = () => {
|
type ImmersiveTapHandlers = {
|
||||||
|
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||||
|
onPointerCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailPageImpl = ({
|
||||||
|
immersive,
|
||||||
|
chromeVisible,
|
||||||
|
immersiveTapHandlers,
|
||||||
|
}: {
|
||||||
|
immersive: boolean;
|
||||||
|
chromeVisible: boolean;
|
||||||
|
immersiveTapHandlers?: ImmersiveTapHandlers;
|
||||||
|
}) => {
|
||||||
const {
|
const {
|
||||||
editorService,
|
editorService,
|
||||||
docService,
|
docService,
|
||||||
@@ -170,7 +196,7 @@ const DetailPageImpl = () => {
|
|||||||
|
|
||||||
editor.bindEditorContainer(
|
editor.bindEditorContainer(
|
||||||
editorContainer,
|
editorContainer,
|
||||||
(editorContainer as any).docTitle, // set from proxy
|
editorContainer.docTitle,
|
||||||
scrollViewportRef.current
|
scrollViewportRef.current
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -189,19 +215,36 @@ const DetailPageImpl = () => {
|
|||||||
!enableKeyboardToolbar ||
|
!enableKeyboardToolbar ||
|
||||||
(mode === 'edgeless' && !enableEdgelessEditing);
|
(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 (
|
return (
|
||||||
<FrameworkScope scope={editor.scope}>
|
<FrameworkScope scope={editor.scope}>
|
||||||
<div className={styles.mainContainer}>
|
<div className={styles.mainContainer}>
|
||||||
<div
|
<div
|
||||||
data-mode={mode}
|
data-mode={mode}
|
||||||
|
data-lock-document-scroll={lockDocumentScroll ? 'true' : undefined}
|
||||||
ref={scrollViewportRef}
|
ref={scrollViewportRef}
|
||||||
|
style={immersiveViewportStyle}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'affine-page-viewport',
|
'affine-page-viewport',
|
||||||
styles.affineDocViewport,
|
styles.affineDocViewport,
|
||||||
styles.editorContainer
|
styles.editorContainer
|
||||||
)}
|
)}
|
||||||
|
onPointerDown={immersiveTapHandlers?.onPointerDown}
|
||||||
|
onPointerUp={immersiveTapHandlers?.onPointerUp}
|
||||||
|
onPointerCancel={immersiveTapHandlers?.onPointerCancel}
|
||||||
>
|
>
|
||||||
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
|
|
||||||
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
|
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
|
||||||
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
|
||||||
</AffineErrorBoundary>
|
</AffineErrorBoundary>
|
||||||
@@ -228,56 +271,70 @@ const skeletonWithBack = getSkeleton(true);
|
|||||||
const notFound = getNotFound(false);
|
const notFound = getNotFound(false);
|
||||||
const notFoundWithBack = getNotFound(true);
|
const notFoundWithBack = getNotFound(true);
|
||||||
|
|
||||||
const checkShowTitle = () => window.scrollY >= 158;
|
const getShouldShowTitle = () =>
|
||||||
|
shouldShowMobileDetailPageTitle(window.scrollY);
|
||||||
|
|
||||||
const MobileDetailPage = ({
|
const LANDSCAPE_MEASUREMENT_MAX_RETRIES = 4;
|
||||||
pageId,
|
|
||||||
|
const getIsLandscape = () =>
|
||||||
|
isLandscapeWindow({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
matchesLandscape: window.matchMedia('(orientation: landscape)').matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MobileDetailPageHeader = ({
|
||||||
date,
|
date,
|
||||||
|
fromTab,
|
||||||
|
title,
|
||||||
|
allJournalDates,
|
||||||
|
handleDateChange,
|
||||||
|
trackScrollTitle,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
|
||||||
date?: string;
|
date?: string;
|
||||||
|
fromTab: boolean;
|
||||||
|
title?: string;
|
||||||
|
allJournalDates: Set<string | null | undefined>;
|
||||||
|
handleDateChange: (date: string) => void;
|
||||||
|
trackScrollTitle: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const docDisplayMetaService = useService(DocDisplayMetaService);
|
const [showTitle, setShowTitle] = useState(getShouldShowTitle);
|
||||||
const journalService = useService(JournalService);
|
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
|
||||||
const [showTitle, setShowTitle] = useState(checkShowTitle);
|
|
||||||
const title = useLiveData(docDisplayMetaService.title$(pageId));
|
|
||||||
|
|
||||||
const canAccess = useGuard('Doc_Read', pageId);
|
useEffect(() => {
|
||||||
|
if (!trackScrollTitle) {
|
||||||
const allJournalDates = useLiveData(journalService.allJournalDates$);
|
return;
|
||||||
|
|
||||||
const location = useLiveData(workbench.location$);
|
|
||||||
const fromTab = location.search.includes('fromTab');
|
|
||||||
|
|
||||||
const handleDateChange = useCallback(
|
|
||||||
(date: string) => {
|
|
||||||
const docs = journalService.journalsByDate$(date).value;
|
|
||||||
if (docs.length > 0) {
|
|
||||||
workbench.openDoc(
|
|
||||||
{ docId: docs[0].id, fromTab: fromTab ? 'true' : undefined },
|
|
||||||
{ replaceHistory: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
workbench.open(`/journals?date=${date}`);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[fromTab, journalService, workbench]
|
|
||||||
);
|
|
||||||
|
|
||||||
useGlobalEvent(
|
let frame = 0;
|
||||||
'scroll',
|
|
||||||
useCallback(() => setShowTitle(checkShowTitle()), [])
|
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 (
|
return (
|
||||||
<div className={styles.root}>
|
|
||||||
<DetailPageWrapper
|
|
||||||
skeleton={date ? skeleton : skeletonWithBack}
|
|
||||||
notFound={date ? notFound : notFoundWithBack}
|
|
||||||
pageId={pageId}
|
|
||||||
canAccess={canAccess}
|
|
||||||
>
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
back={!fromTab}
|
back={!fromTab}
|
||||||
className={styles.header}
|
className={styles.header}
|
||||||
@@ -306,9 +363,222 @@ const MobileDetailPage = ({
|
|||||||
: title}
|
: title}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileDetailPageContent = ({
|
||||||
|
pageId,
|
||||||
|
date,
|
||||||
|
fromTab,
|
||||||
|
title,
|
||||||
|
allJournalDates,
|
||||||
|
handleDateChange,
|
||||||
|
}: {
|
||||||
|
pageId: string;
|
||||||
|
date?: string;
|
||||||
|
fromTab: boolean;
|
||||||
|
title?: string;
|
||||||
|
allJournalDates: Set<string | null | undefined>;
|
||||||
|
handleDateChange: (date: string) => void;
|
||||||
|
}) => {
|
||||||
|
const editor = useService(EditorService).editor;
|
||||||
|
const mode = useLiveData(editor.mode$);
|
||||||
|
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
|
||||||
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
|
const tapStateRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
tappable: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape });
|
||||||
|
const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(orientation: landscape)');
|
||||||
|
let frame = 0;
|
||||||
|
let disposed = false;
|
||||||
|
let remainingRetries = 0;
|
||||||
|
|
||||||
|
const sampleLandscape = () => {
|
||||||
|
frame = 0;
|
||||||
|
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurement = getLandscapeWindowMeasurement({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
matchesLandscape: mediaQuery.matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLandscape(prev => {
|
||||||
|
const next = measurement.isLandscape;
|
||||||
|
return prev === next ? prev : next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!measurement.settled && remainingRetries > 0) {
|
||||||
|
remainingRetries -= 1;
|
||||||
|
frame = window.requestAnimationFrame(sampleLandscape);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLandscape = () => {
|
||||||
|
if (frame) {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingRetries = LANDSCAPE_MEASUREMENT_MAX_RETRIES;
|
||||||
|
frame = window.requestAnimationFrame(sampleLandscape);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateLandscape();
|
||||||
|
window.addEventListener('resize', updateLandscape);
|
||||||
|
mediaQuery.addEventListener('change', updateLandscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (frame) {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updateLandscape);
|
||||||
|
mediaQuery.removeEventListener('change', updateLandscape);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChromeVisible(!immersive);
|
||||||
|
tapStateRef.current = null;
|
||||||
|
}, [immersive, pageId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersive || !chromeVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setChromeVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [chromeVisible, immersive]);
|
||||||
|
|
||||||
|
const immersiveTapHandlers = useMemo<ImmersiveTapHandlers | undefined>(() => {
|
||||||
|
if (!immersive) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown: event => {
|
||||||
|
tapStateRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
tappable: isImmersiveTapTarget(event.target),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onPointerUp: event => {
|
||||||
|
const tapState = tapStateRef.current;
|
||||||
|
tapStateRef.current = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!tapState ||
|
||||||
|
tapState.pointerId !== event.pointerId ||
|
||||||
|
!tapState.tappable ||
|
||||||
|
!isTapWithinSlop(tapState, event)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChromeVisible(visible => !visible);
|
||||||
|
},
|
||||||
|
onPointerCancel: () => {
|
||||||
|
tapStateRef.current = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [immersive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(!immersive || chromeVisible) && (
|
||||||
|
<MobileDetailPageHeader
|
||||||
|
date={date}
|
||||||
|
fromTab={fromTab}
|
||||||
|
title={title}
|
||||||
|
allJournalDates={allJournalDates}
|
||||||
|
handleDateChange={handleDateChange}
|
||||||
|
trackScrollTitle={trackScrollTitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<JournalConflictBlock date={date} />
|
<JournalConflictBlock date={date} />
|
||||||
<DetailPageImpl />
|
<DetailPageImpl
|
||||||
<AppTabs background={cssVarV2('layer/background/primary')} />
|
immersive={immersive}
|
||||||
|
chromeVisible={chromeVisible}
|
||||||
|
immersiveTapHandlers={immersiveTapHandlers}
|
||||||
|
/>
|
||||||
|
<AppTabs
|
||||||
|
background={cssVarV2('layer/background/primary')}
|
||||||
|
hidden={immersive && !chromeVisible}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileDetailPage = ({
|
||||||
|
pageId,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
pageId: string;
|
||||||
|
date?: string;
|
||||||
|
}) => {
|
||||||
|
const docDisplayMetaService = useService(DocDisplayMetaService);
|
||||||
|
const journalService = useService(JournalService);
|
||||||
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
const title = useLiveData(docDisplayMetaService.title$(pageId));
|
||||||
|
|
||||||
|
const canAccess = useGuard('Doc_Read', pageId);
|
||||||
|
|
||||||
|
const allJournalDates = useLiveData(journalService.allJournalDates$);
|
||||||
|
|
||||||
|
const location = useLiveData(workbench.location$);
|
||||||
|
const fromTab = location.search.includes('fromTab');
|
||||||
|
|
||||||
|
const handleDateChange = useCallback(
|
||||||
|
(date: string) => {
|
||||||
|
const docs = journalService.journalsByDate$(date).value;
|
||||||
|
if (docs.length > 0) {
|
||||||
|
workbench.openDoc(
|
||||||
|
{ docId: docs[0].id, fromTab: fromTab ? 'true' : undefined },
|
||||||
|
{ replaceHistory: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
workbench.open(`/journals?date=${date}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fromTab, journalService, workbench]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<DetailPageWrapper
|
||||||
|
skeleton={date ? skeleton : skeletonWithBack}
|
||||||
|
notFound={date ? notFound : notFoundWithBack}
|
||||||
|
pageId={pageId}
|
||||||
|
canAccess={canAccess}
|
||||||
|
>
|
||||||
|
<MobileDetailPageContent
|
||||||
|
key={pageId}
|
||||||
|
pageId={pageId}
|
||||||
|
date={date}
|
||||||
|
fromTab={fromTab}
|
||||||
|
title={title}
|
||||||
|
allJournalDates={allJournalDates}
|
||||||
|
handleDateChange={handleDateChange}
|
||||||
|
/>
|
||||||
</DetailPageWrapper>
|
</DetailPageWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ globalStyle(':root', {
|
|||||||
vars: {
|
vars: {
|
||||||
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||||
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||||
|
'--affine-edgeless-zoom-toolbar-bottom': `calc(10px + ${globalVars.appTabSafeArea})`,
|
||||||
},
|
},
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|||||||
@@ -318,29 +318,71 @@ export class Editor extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update scroll position when scrollViewport scroll
|
// update scroll position when scrollViewport scroll
|
||||||
const saveScrollPosition = () => {
|
let edgelessWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
if (this.mode$.value === 'page' && scrollViewport) {
|
|
||||||
|
const flushEdgelessScrollPosition = () => {
|
||||||
|
if (edgelessWriteTimer) {
|
||||||
|
clearTimeout(edgelessWriteTimer);
|
||||||
|
edgelessWriteTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = this.scrollPosition.edgeless;
|
||||||
|
if (!pos) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.workbenchView?.setScrollPosition(pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePageScrollPosition = () => {
|
||||||
|
if (!scrollViewport || this.mode$.value !== 'page') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.scrollPosition.page = scrollViewport.scrollTop;
|
this.scrollPosition.page = scrollViewport.scrollTop;
|
||||||
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
|
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
|
||||||
} else if (this.mode$.value === 'edgeless' && gfx) {
|
};
|
||||||
const pos = {
|
|
||||||
|
const saveEdgelessScrollPosition = () => {
|
||||||
|
if (!gfx || this.mode$.value !== 'edgeless') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollPosition.edgeless = {
|
||||||
centerX: gfx.viewport.centerX,
|
centerX: gfx.viewport.centerX,
|
||||||
centerY: gfx.viewport.centerY,
|
centerY: gfx.viewport.centerY,
|
||||||
zoom: gfx.viewport.zoom,
|
zoom: gfx.viewport.zoom,
|
||||||
};
|
};
|
||||||
this.scrollPosition.edgeless = pos;
|
|
||||||
this.workbenchView?.setScrollPosition(pos);
|
if (edgelessWriteTimer) {
|
||||||
|
clearTimeout(edgelessWriteTimer);
|
||||||
}
|
}
|
||||||
|
edgelessWriteTimer = setTimeout(() => {
|
||||||
|
flushEdgelessScrollPosition();
|
||||||
|
}, 160);
|
||||||
};
|
};
|
||||||
scrollViewport?.addEventListener('scroll', saveScrollPosition);
|
|
||||||
|
const handleViewportScroll = () => {
|
||||||
|
if (this.mode$.value === 'edgeless' && scrollViewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savePageScrollPosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollViewport?.addEventListener('scroll', handleViewportScroll);
|
||||||
unsubs.push(() => {
|
unsubs.push(() => {
|
||||||
scrollViewport?.removeEventListener('scroll', saveScrollPosition);
|
scrollViewport?.removeEventListener('scroll', handleViewportScroll);
|
||||||
});
|
});
|
||||||
if (gfx) {
|
if (gfx) {
|
||||||
const subscription =
|
const subscription = gfx.viewport.viewportUpdated.subscribe(() => {
|
||||||
gfx.viewport.viewportUpdated.subscribe(saveScrollPosition);
|
saveEdgelessScrollPosition();
|
||||||
|
});
|
||||||
unsubs.push(subscription.unsubscribe.bind(subscription));
|
unsubs.push(subscription.unsubscribe.bind(subscription));
|
||||||
}
|
}
|
||||||
|
unsubs.push(() => {
|
||||||
|
flushEdgelessScrollPosition();
|
||||||
|
});
|
||||||
|
|
||||||
// update selection when focusAt$ changed
|
// update selection when focusAt$ changed
|
||||||
const subscription = this.focusAt$
|
const subscription = this.focusAt$
|
||||||
|
|||||||
@@ -683,6 +683,7 @@ export const PackageList = [
|
|||||||
location: 'blocksuite/affine/gfx/turbo-renderer',
|
location: 'blocksuite/affine/gfx/turbo-renderer',
|
||||||
name: '@blocksuite/affine-gfx-turbo-renderer',
|
name: '@blocksuite/affine-gfx-turbo-renderer',
|
||||||
workspaceDependencies: [
|
workspaceDependencies: [
|
||||||
|
'blocksuite/affine/shared',
|
||||||
'blocksuite/framework/global',
|
'blocksuite/framework/global',
|
||||||
'blocksuite/framework/std',
|
'blocksuite/framework/std',
|
||||||
'blocksuite/framework/store',
|
'blocksuite/framework/store',
|
||||||
|
|||||||
@@ -2662,6 +2662,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@blocksuite/affine-gfx-turbo-renderer@workspace:blocksuite/affine/gfx/turbo-renderer"
|
resolution: "@blocksuite/affine-gfx-turbo-renderer@workspace:blocksuite/affine/gfx/turbo-renderer"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@blocksuite/affine-shared": "workspace:*"
|
||||||
"@blocksuite/global": "workspace:*"
|
"@blocksuite/global": "workspace:*"
|
||||||
"@blocksuite/std": "workspace:*"
|
"@blocksuite/std": "workspace:*"
|
||||||
"@blocksuite/store": "workspace:*"
|
"@blocksuite/store": "workspace:*"
|
||||||
|
|||||||
Reference in New Issue
Block a user