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

fix bug edgeless can't slider with finger 

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

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

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-06-16 21:19:31 +08:00
committed by GitHub
parent c51bdb74de
commit a77d89bb1a
43 changed files with 4749 additions and 273 deletions
@@ -19,9 +19,11 @@ import type { AppTabLink } from './type';
export const AppTabs = ({
background,
fixed = true,
hidden = false,
}: {
background?: string;
fixed?: boolean;
hidden?: boolean;
}) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.visible$);
@@ -47,7 +49,8 @@ export const AppTabs = ({
...assignInlineVars({
[styles.appTabsBackground]: background,
}),
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
visibility: hidden || virtualKeyboardVisible ? 'hidden' : 'visible',
pointerEvents: hidden || virtualKeyboardVisible ? 'none' : 'auto',
}}
>
<ul className={styles.appTabsInner} role="tablist">
@@ -8,6 +8,13 @@ export const root = style({
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
selectors: {
'&:has([data-mode="edgeless"])': {
height: '100dvh',
maxHeight: '100dvh',
overflow: 'hidden',
},
},
});
export const header = style({
@@ -76,6 +83,10 @@ export const affineDocViewport = style({
left: 0,
right: 0,
bottom: 0,
containerType: 'normal',
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
},
},
});
@@ -122,3 +133,27 @@ export const journalIconButton = style({
export const journalDatePicker = style({
background: cssVarV2('layer/background/primary'),
});
// When edgeless mode is active, prevent document-level scrolling
// so native scrollView pan gestures don't scroll the page away from the canvas
globalStyle('html:has([data-lock-document-scroll="true"])', {
overflow: 'hidden',
height: '100dvh',
overscrollBehavior: 'none',
});
globalStyle('body:has([data-lock-document-scroll="true"])', {
height: '100dvh',
minHeight: '100dvh',
overflow: 'hidden',
overscrollBehavior: 'none',
});
globalStyle('body:has([data-lock-document-scroll="true"]):has(>#app-tabs)', {
paddingBottom: 0,
});
// Prevent native touch handling on edgeless viewport so canvas handles all gestures
globalStyle('[data-mode="edgeless"] .affine-edgeless-viewport', {
touchAction: 'none',
});
@@ -0,0 +1,190 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import * as immersiveModule from './mobile-detail-page.immersive';
import {
getImmersiveZoomToolbarBottom,
isImmersiveTapTarget,
isLandscapeWindow,
isTapWithinSlop,
shouldEnableEdgelessImmersive,
shouldLockEdgelessDocumentScroll,
shouldShowMobileDetailPageTitle,
shouldTrackMobileDetailPageTitleScroll,
} from './mobile-detail-page.immersive';
describe('mobile detail page immersive helpers', () => {
test('enables immersive mode only for edgeless landscape', () => {
expect(
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: true })
).toBe(true);
expect(
shouldEnableEdgelessImmersive({ mode: 'page', isLandscape: true })
).toBe(false);
expect(
shouldEnableEdgelessImmersive({ mode: 'edgeless', isLandscape: false })
).toBe(false);
});
test('treats window as landscape only when media query and geometry agree', () => {
expect(
isLandscapeWindow({
width: 844,
height: 390,
matchesLandscape: true,
})
).toBe(true);
expect(
isLandscapeWindow({
width: 390,
height: 844,
matchesLandscape: true,
})
).toBe(false);
expect(
isLandscapeWindow({
width: 844,
height: 390,
matchesLandscape: false,
})
).toBe(false);
});
test('marks mismatched orientation signals as unsettled so immersive mode can retry the first rotation sample', () => {
expect('getLandscapeWindowMeasurement' in immersiveModule).toBe(true);
const getLandscapeWindowMeasurement = (
immersiveModule as {
getLandscapeWindowMeasurement: (params: {
width: number;
height: number;
matchesLandscape: boolean;
}) => {
isLandscape: boolean;
settled: boolean;
};
}
).getLandscapeWindowMeasurement;
expect(
getLandscapeWindowMeasurement({
width: 844,
height: 390,
matchesLandscape: false,
})
).toEqual({
isLandscape: false,
settled: false,
});
expect(
getLandscapeWindowMeasurement({
width: 390,
height: 844,
matchesLandscape: true,
})
).toEqual({
isLandscape: false,
settled: false,
});
expect(
getLandscapeWindowMeasurement({
width: 844,
height: 390,
matchesLandscape: true,
})
).toEqual({
isLandscape: true,
settled: true,
});
});
test('ignores taps from edgeless toolbar chrome targets', () => {
const toolbar = document.createElement('edgeless-toolbar-widget');
const toolbarButton = document.createElement('button');
toolbar.append(toolbarButton);
const zoomToolbar = document.createElement('div');
zoomToolbar.className = 'edgeless-zoom-toolbar-container';
const zoomButton = document.createElement('button');
zoomToolbar.append(zoomButton);
const selectedRect = document.createElement('div');
selectedRect.className = 'affine-edgeless-selected-rect';
const resizeHandle = document.createElement('div');
selectedRect.append(resizeHandle);
const canvas = document.createElement('div');
document.body.append(toolbar, zoomToolbar, selectedRect, canvas);
expect(isImmersiveTapTarget(toolbarButton)).toBe(false);
expect(isImmersiveTapTarget(zoomButton)).toBe(false);
expect(isImmersiveTapTarget(resizeHandle)).toBe(false);
expect(isImmersiveTapTarget(canvas)).toBe(true);
expect(isImmersiveTapTarget(null)).toBe(false);
});
test('accepts only small pointer movement as a tap', () => {
expect(
isTapWithinSlop(
{ clientX: 100, clientY: 200 },
{ clientX: 104, clientY: 205 }
)
).toBe(true);
expect(
isTapWithinSlop(
{ clientX: 100, clientY: 200 },
{ clientX: 120, clientY: 205 }
)
).toBe(false);
});
test('raises zoom toolbar above tab bar only when immersive chrome is visible', () => {
expect(
getImmersiveZoomToolbarBottom({
immersive: true,
chromeVisible: false,
})
).toBe('10px');
expect(
getImmersiveZoomToolbarBottom({
immersive: true,
chromeVisible: true,
tabBarOffset: 'var(--appTabSafeArea)',
})
).toBe('calc(10px + var(--appTabSafeArea))');
expect(
getImmersiveZoomToolbarBottom({
immersive: false,
chromeVisible: true,
tabBarOffset: 'var(--appTabSafeArea)',
})
).toBeUndefined();
});
test('locks document scroll whenever edgeless mode is active', () => {
expect(shouldLockEdgelessDocumentScroll('edgeless')).toBe(true);
expect(shouldLockEdgelessDocumentScroll('page')).toBe(false);
});
test('tracks title scroll only in page mode', () => {
expect(shouldTrackMobileDetailPageTitleScroll('page')).toBe(true);
expect(shouldTrackMobileDetailPageTitleScroll('edgeless')).toBe(false);
});
test('shows title only after crossing the existing scroll threshold', () => {
expect(shouldShowMobileDetailPageTitle(157)).toBe(false);
expect(shouldShowMobileDetailPageTitle(158)).toBe(true);
expect(shouldShowMobileDetailPageTitle(240)).toBe(true);
});
});
@@ -0,0 +1,105 @@
export const EDGELESS_IMMERSIVE_TAP_SLOP = 8;
const IMMERSIVE_TAP_EXCLUDE_SELECTORS = [
'edgeless-toolbar-widget',
'.edgeless-toolbar-container',
'affine-edgeless-zoom-toolbar-widget',
'.edgeless-zoom-toolbar-container',
'.affine-edgeless-selected-rect',
].join(', ');
export function getLandscapeWindowMeasurement({
width,
height,
matchesLandscape,
}: {
width: number;
height: number;
matchesLandscape: boolean;
}) {
const geometryLandscape = width > height;
return {
isLandscape: matchesLandscape && geometryLandscape,
settled: matchesLandscape === geometryLandscape,
};
}
export function isLandscapeWindow({
width,
height,
matchesLandscape,
}: {
width: number;
height: number;
matchesLandscape: boolean;
}) {
return getLandscapeWindowMeasurement({
width,
height,
matchesLandscape,
}).isLandscape;
}
export function shouldEnableEdgelessImmersive({
mode,
isLandscape,
}: {
mode: 'page' | 'edgeless';
isLandscape: boolean;
}) {
return mode === 'edgeless' && isLandscape;
}
export function shouldLockEdgelessDocumentScroll(mode: 'page' | 'edgeless') {
return mode === 'edgeless';
}
export function shouldTrackMobileDetailPageTitleScroll(
mode: 'page' | 'edgeless'
) {
return mode === 'page';
}
export function shouldShowMobileDetailPageTitle(scrollY: number) {
return scrollY >= 158;
}
export function isImmersiveTapTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false;
}
return !target.closest(IMMERSIVE_TAP_EXCLUDE_SELECTORS);
}
export function isTapWithinSlop(
start: { clientX: number; clientY: number },
end: { clientX: number; clientY: number },
slop = EDGELESS_IMMERSIVE_TAP_SLOP
) {
return (
Math.abs(start.clientX - end.clientX) <= slop &&
Math.abs(start.clientY - end.clientY) <= slop
);
}
export function getImmersiveZoomToolbarBottom({
immersive,
chromeVisible,
tabBarOffset,
}: {
immersive: boolean;
chromeVisible: boolean;
tabBarOffset?: string;
}) {
if (!immersive) {
return undefined;
}
if (chromeVisible && tabBarOffset) {
return `calc(10px + ${tabBarOffset})`;
}
return '10px';
}
@@ -8,7 +8,6 @@ import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-he
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper';
import { PageHeader } from '@affine/core/mobile/components';
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
import { AIButtonService } from '@affine/core/modules/ai-button';
import { ServerService } from '@affine/core/modules/cloud';
import { DocService } from '@affine/core/modules/doc';
@@ -36,17 +35,44 @@ import {
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { globalVars } from '../../../styles/variables.css';
import { JournalConflictBlock } from './journal-conflict-block';
import { JournalDatePicker } from './journal-date-picker';
import * as styles from './mobile-detail-page.css';
import {
getImmersiveZoomToolbarBottom,
getLandscapeWindowMeasurement,
isImmersiveTapTarget,
isLandscapeWindow,
isTapWithinSlop,
shouldEnableEdgelessImmersive,
shouldLockEdgelessDocumentScroll,
shouldShowMobileDetailPageTitle,
shouldTrackMobileDetailPageTitleScroll,
} from './mobile-detail-page.immersive';
import { PageHeaderMenuButton } from './page-header-more-button';
import { PageHeaderShareButton } from './page-header-share-button';
const DetailPageImpl = () => {
type ImmersiveTapHandlers = {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: () => void;
};
const DetailPageImpl = ({
immersive,
chromeVisible,
immersiveTapHandlers,
}: {
immersive: boolean;
chromeVisible: boolean;
immersiveTapHandlers?: ImmersiveTapHandlers;
}) => {
const {
editorService,
docService,
@@ -170,7 +196,7 @@ const DetailPageImpl = () => {
editor.bindEditorContainer(
editorContainer,
(editorContainer as any).docTitle, // set from proxy
editorContainer.docTitle,
scrollViewportRef.current
);
@@ -189,19 +215,36 @@ const DetailPageImpl = () => {
!enableKeyboardToolbar ||
(mode === 'edgeless' && !enableEdgelessEditing);
const immersiveZoomToolbarBottom = getImmersiveZoomToolbarBottom({
immersive,
chromeVisible,
tabBarOffset: globalVars.appTabSafeArea,
});
const lockDocumentScroll = shouldLockEdgelessDocumentScroll(mode);
const immersiveViewportStyle = immersiveZoomToolbarBottom
? ({
'--affine-edgeless-zoom-toolbar-bottom': immersiveZoomToolbarBottom,
} as CSSProperties)
: undefined;
return (
<FrameworkScope scope={editor.scope}>
<div className={styles.mainContainer}>
<div
data-mode={mode}
data-lock-document-scroll={lockDocumentScroll ? 'true' : undefined}
ref={scrollViewportRef}
style={immersiveViewportStyle}
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
)}
onPointerDown={immersiveTapHandlers?.onPointerDown}
onPointerUp={immersiveTapHandlers?.onPointerUp}
onPointerCancel={immersiveTapHandlers?.onPointerCancel}
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id} className={styles.errorBoundary}>
<PageDetailEditor onLoad={onLoad} readonly={readonly} />
</AffineErrorBoundary>
@@ -228,7 +271,262 @@ const skeletonWithBack = getSkeleton(true);
const notFound = getNotFound(false);
const notFoundWithBack = getNotFound(true);
const checkShowTitle = () => window.scrollY >= 158;
const getShouldShowTitle = () =>
shouldShowMobileDetailPageTitle(window.scrollY);
const LANDSCAPE_MEASUREMENT_MAX_RETRIES = 4;
const getIsLandscape = () =>
isLandscapeWindow({
width: window.innerWidth,
height: window.innerHeight,
matchesLandscape: window.matchMedia('(orientation: landscape)').matches,
});
const MobileDetailPageHeader = ({
date,
fromTab,
title,
allJournalDates,
handleDateChange,
trackScrollTitle,
}: {
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
trackScrollTitle: boolean;
}) => {
const [showTitle, setShowTitle] = useState(getShouldShowTitle);
useEffect(() => {
if (!trackScrollTitle) {
return;
}
let frame = 0;
const updateShowTitle = () => {
frame = 0;
setShowTitle(prev => {
const next = getShouldShowTitle();
return prev === next ? prev : next;
});
};
const handleScroll = () => {
if (frame) {
return;
}
frame = window.requestAnimationFrame(updateShowTitle);
};
window.addEventListener('scroll', handleScroll);
handleScroll();
return () => {
if (frame) {
window.cancelAnimationFrame(frame);
}
window.removeEventListener('scroll', handleScroll);
};
}, [trackScrollTitle]);
return (
<PageHeader
back={!fromTab}
className={styles.header}
contentClassName={styles.headerContent}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
bottom={
date ? (
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
) : null
}
bottomSpacer={94}
>
<span data-show={!!date || showTitle} className={styles.headerTitle}>
{date
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
: title}
</span>
</PageHeader>
);
};
const MobileDetailPageContent = ({
pageId,
date,
fromTab,
title,
allJournalDates,
handleDateChange,
}: {
pageId: string;
date?: string;
fromTab: boolean;
title?: string;
allJournalDates: Set<string | null | undefined>;
handleDateChange: (date: string) => void;
}) => {
const editor = useService(EditorService).editor;
const mode = useLiveData(editor.mode$);
const [isLandscape, setIsLandscape] = useState(getIsLandscape);
const [chromeVisible, setChromeVisible] = useState(true);
const tapStateRef = useRef<{
pointerId: number;
clientX: number;
clientY: number;
tappable: boolean;
} | null>(null);
const immersive = shouldEnableEdgelessImmersive({ mode, isLandscape });
const trackScrollTitle = shouldTrackMobileDetailPageTitleScroll(mode);
useEffect(() => {
const mediaQuery = window.matchMedia('(orientation: landscape)');
let frame = 0;
let disposed = false;
let remainingRetries = 0;
const sampleLandscape = () => {
frame = 0;
if (disposed) {
return;
}
const measurement = getLandscapeWindowMeasurement({
width: window.innerWidth,
height: window.innerHeight,
matchesLandscape: mediaQuery.matches,
});
setIsLandscape(prev => {
const next = measurement.isLandscape;
return prev === next ? prev : next;
});
if (!measurement.settled && remainingRetries > 0) {
remainingRetries -= 1;
frame = window.requestAnimationFrame(sampleLandscape);
}
};
const updateLandscape = () => {
if (frame) {
window.cancelAnimationFrame(frame);
}
remainingRetries = LANDSCAPE_MEASUREMENT_MAX_RETRIES;
frame = window.requestAnimationFrame(sampleLandscape);
};
updateLandscape();
window.addEventListener('resize', updateLandscape);
mediaQuery.addEventListener('change', updateLandscape);
return () => {
disposed = true;
if (frame) {
window.cancelAnimationFrame(frame);
}
window.removeEventListener('resize', updateLandscape);
mediaQuery.removeEventListener('change', updateLandscape);
};
}, []);
useEffect(() => {
setChromeVisible(!immersive);
tapStateRef.current = null;
}, [immersive, pageId]);
useEffect(() => {
if (!immersive || !chromeVisible) {
return;
}
const timeout = window.setTimeout(() => {
setChromeVisible(false);
}, 3000);
return () => {
window.clearTimeout(timeout);
};
}, [chromeVisible, immersive]);
const immersiveTapHandlers = useMemo<ImmersiveTapHandlers | undefined>(() => {
if (!immersive) {
return undefined;
}
return {
onPointerDown: event => {
tapStateRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
tappable: isImmersiveTapTarget(event.target),
};
},
onPointerUp: event => {
const tapState = tapStateRef.current;
tapStateRef.current = null;
if (
!tapState ||
tapState.pointerId !== event.pointerId ||
!tapState.tappable ||
!isTapWithinSlop(tapState, event)
) {
return;
}
setChromeVisible(visible => !visible);
},
onPointerCancel: () => {
tapStateRef.current = null;
},
};
}, [immersive]);
return (
<>
{(!immersive || chromeVisible) && (
<MobileDetailPageHeader
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
trackScrollTitle={trackScrollTitle}
/>
)}
<JournalConflictBlock date={date} />
<DetailPageImpl
immersive={immersive}
chromeVisible={chromeVisible}
immersiveTapHandlers={immersiveTapHandlers}
/>
<AppTabs
background={cssVarV2('layer/background/primary')}
hidden={immersive && !chromeVisible}
/>
</>
);
};
const MobileDetailPage = ({
pageId,
@@ -240,7 +538,6 @@ const MobileDetailPage = ({
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const [showTitle, setShowTitle] = useState(checkShowTitle);
const title = useLiveData(docDisplayMetaService.title$(pageId));
const canAccess = useGuard('Doc_Read', pageId);
@@ -265,11 +562,6 @@ const MobileDetailPage = ({
[fromTab, journalService, workbench]
);
useGlobalEvent(
'scroll',
useCallback(() => setShowTitle(checkShowTitle()), [])
);
return (
<div className={styles.root}>
<DetailPageWrapper
@@ -278,37 +570,15 @@ const MobileDetailPage = ({
pageId={pageId}
canAccess={canAccess}
>
<PageHeader
back={!fromTab}
className={styles.header}
contentClassName={styles.headerContent}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
bottom={
date ? (
<JournalDatePicker
date={date}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
) : null
}
bottomSpacer={94}
>
<span data-show={!!date || showTitle} className={styles.headerTitle}>
{date
? i18nTime(dayjs(date), { absolute: { accuracy: 'month' } })
: title}
</span>
</PageHeader>
<JournalConflictBlock date={date} />
<DetailPageImpl />
<AppTabs background={cssVarV2('layer/background/primary')} />
<MobileDetailPageContent
key={pageId}
pageId={pageId}
date={date}
fromTab={fromTab}
title={title}
allJournalDates={allJournalDates}
handleDateChange={handleDateChange}
/>
</DetailPageWrapper>
</div>
);
@@ -7,6 +7,7 @@ globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
'--affine-edgeless-zoom-toolbar-bottom': `calc(10px + ${globalVars.appTabSafeArea})`,
},
userSelect: 'none',
WebkitUserSelect: 'none',
@@ -318,29 +318,71 @@ export class Editor extends Entity {
}
// update scroll position when scrollViewport scroll
const saveScrollPosition = () => {
if (this.mode$.value === 'page' && scrollViewport) {
this.scrollPosition.page = scrollViewport.scrollTop;
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
} else if (this.mode$.value === 'edgeless' && gfx) {
const pos = {
centerX: gfx.viewport.centerX,
centerY: gfx.viewport.centerY,
zoom: gfx.viewport.zoom,
};
this.scrollPosition.edgeless = pos;
this.workbenchView?.setScrollPosition(pos);
let edgelessWriteTimer: ReturnType<typeof setTimeout> | null = null;
const flushEdgelessScrollPosition = () => {
if (edgelessWriteTimer) {
clearTimeout(edgelessWriteTimer);
edgelessWriteTimer = null;
}
const pos = this.scrollPosition.edgeless;
if (!pos) {
return;
}
this.workbenchView?.setScrollPosition(pos);
};
scrollViewport?.addEventListener('scroll', saveScrollPosition);
const savePageScrollPosition = () => {
if (!scrollViewport || this.mode$.value !== 'page') {
return;
}
this.scrollPosition.page = scrollViewport.scrollTop;
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
};
const saveEdgelessScrollPosition = () => {
if (!gfx || this.mode$.value !== 'edgeless') {
return;
}
this.scrollPosition.edgeless = {
centerX: gfx.viewport.centerX,
centerY: gfx.viewport.centerY,
zoom: gfx.viewport.zoom,
};
if (edgelessWriteTimer) {
clearTimeout(edgelessWriteTimer);
}
edgelessWriteTimer = setTimeout(() => {
flushEdgelessScrollPosition();
}, 160);
};
const handleViewportScroll = () => {
if (this.mode$.value === 'edgeless' && scrollViewport) {
return;
}
savePageScrollPosition();
};
scrollViewport?.addEventListener('scroll', handleViewportScroll);
unsubs.push(() => {
scrollViewport?.removeEventListener('scroll', saveScrollPosition);
scrollViewport?.removeEventListener('scroll', handleViewportScroll);
});
if (gfx) {
const subscription =
gfx.viewport.viewportUpdated.subscribe(saveScrollPosition);
const subscription = gfx.viewport.viewportUpdated.subscribe(() => {
saveEdgelessScrollPosition();
});
unsubs.push(subscription.unsubscribe.bind(subscription));
}
unsubs.push(() => {
flushEdgelessScrollPosition();
});
// update selection when focusAt$ changed
const subscription = this.focusAt$