mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-31 17:19:56 +08:00
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:
@@ -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',
|
||||
});
|
||||
|
||||
+190
@@ -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);
|
||||
});
|
||||
});
|
||||
+105
@@ -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$
|
||||
|
||||
Reference in New Issue
Block a user