diff --git a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx
index cd8f19b3b1..55e54dd69b 100644
--- a/packages/frontend/core/src/mobile/components/app-tabs/index.tsx
+++ b/packages/frontend/core/src/mobile/components/app-tabs/index.tsx
@@ -7,6 +7,7 @@ import { AllDocsIcon, MobileHomeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import React from 'react';
+import { createPortal } from 'react-dom';
import type { Location } from 'react-router-dom';
import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard';
@@ -58,7 +59,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
- return (
+ return createPortal(
{
}
})}
-
+ ,
+ document.body
);
};
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx
index d28cd896f1..be57480425 100644
--- a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx
+++ b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx
@@ -1,5 +1,4 @@
-import { Modal, Scrollable, Switch } from '@affine/component';
-import { PageHeader } from '@affine/core/mobile/components';
+import { Switch } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import {
@@ -13,6 +12,7 @@ import { useCallback, useState } from 'react';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
+import { SwipeDialog } from '../swipe-dialog';
import * as styles from './styles.css';
export const ExperimentalFeatureSetting = () => {
@@ -28,42 +28,29 @@ export const ExperimentalFeatureSetting = () => {
-
- setOpen(false)} />
-
+
+
>
);
};
-const ExperimentalFeatureList = ({ onBack }: { onBack: () => void }) => {
+const ExperimentalFeatureList = () => {
const featureFlagService = useService(FeatureFlagService);
return (
-
-
- Experimental Features
-
-
-
-
- {Object.keys(AFFINE_FLAGS).map(key => (
-
- ))}
-
-
-
-
-
+
+ {Object.keys(AFFINE_FLAGS).map(key => (
+
+ ))}
+
);
};
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts
index b24de245cc..a2dcd061a1 100644
--- a/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts
+++ b/packages/frontend/core/src/mobile/dialogs/setting/experimental/styles.css.ts
@@ -1,29 +1,7 @@
-import {
- bodyEmphasized,
- bodyRegular,
- footnoteRegular,
-} from '@toeverything/theme/typography';
+import { bodyRegular, footnoteRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
-export const dialog = style({
- padding: '0 !important',
- background: cssVarV2('layer/background/mobile/primary'),
-});
-export const root = style({
- display: 'flex',
- flexDirection: 'column',
- height: '100dvh',
-});
-export const header = style({
- background: `${cssVarV2('layer/background/mobile/primary')} !important`,
-});
-export const dialogTitle = style([bodyEmphasized, {}]);
-export const scrollArea = style({
- height: 0,
- flex: 1,
-});
-
export const content = style({
padding: '24px 16px',
display: 'flex',
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx
index 7c22bf6cf0..65028036cb 100644
--- a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx
+++ b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx
@@ -1,4 +1,3 @@
-import { ConfigModal } from '@affine/core/components/mobile';
import { AuthService } from '@affine/core/modules/cloud';
import type {
DialogComponentProps,
@@ -13,6 +12,7 @@ import { AppearanceGroup } from './appearance';
import { ExperimentalFeatureSetting } from './experimental';
import { OthersGroup } from './others';
import * as styles from './style.css';
+import { SwipeDialog } from './swipe-dialog';
import { UserProfile } from './user-profile';
import { UserUsage } from './user-usage';
@@ -38,13 +38,23 @@ export const SettingDialog = ({
const t = useI18n();
return (
- close()}
- onBack={close}
>
-
+
);
+
+ // return (
+ // close()}
+ // onBack={close}
+ // >
+ //
+ //
+ // );
};
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts
index fe74c0bcd1..cbf0a168b2 100644
--- a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts
+++ b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts
@@ -8,6 +8,7 @@ export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 16,
+ padding: '24px 16px',
});
export const baseSettingItem = style({
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts
new file mode 100644
index 0000000000..e260825fcf
--- /dev/null
+++ b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.css.ts
@@ -0,0 +1,57 @@
+import { cssVar } from '@toeverything/theme';
+import { bodyEmphasized } from '@toeverything/theme/typography';
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { createVar, style } from '@vanilla-extract/css';
+
+export const root = style({
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ zIndex: cssVar('zIndexModal'),
+ width: '100dvw',
+ height: '100dvh',
+});
+export const overlay = style({
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ left: 0,
+ top: 0,
+ background: 'transparent',
+});
+export const dialog = style([
+ overlay,
+ {
+ padding: 0,
+ background: cssVarV2('layer/background/mobile/primary'),
+ // initial state,
+ transform: 'translateX(100%)',
+ },
+]);
+
+export const content = style({
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100dvh',
+});
+
+export const header = style({
+ background: `${cssVarV2('layer/background/mobile/primary')} !important`,
+});
+
+export const dialogTitle = style([bodyEmphasized, {}]);
+export const scrollArea = style({
+ height: 0,
+ flex: 1,
+});
+
+export const triggerSizeVar = createVar('triggerSize');
+export const swipeBackTrigger = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: triggerSizeVar,
+ height: '100%',
+ zIndex: 1,
+});
diff --git a/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx
new file mode 100644
index 0000000000..02a3f1649a
--- /dev/null
+++ b/packages/frontend/core/src/mobile/dialogs/setting/swipe-dialog.tsx
@@ -0,0 +1,238 @@
+import { Scrollable } from '@affine/component';
+import { PageHeader } from '@affine/core/mobile/components';
+import { assignInlineVars } from '@vanilla-extract/dynamic';
+import anime from 'animejs';
+import {
+ createContext,
+ type PropsWithChildren,
+ type RefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from 'react';
+import { createPortal } from 'react-dom';
+
+import { SwipeHelper } from '../../utils';
+import * as styles from './swipe-dialog.css';
+
+export interface SwipeDialogProps extends PropsWithChildren {
+ triggerSize?: number;
+ title?: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+const overlayOpacityRange = [0, 0.1];
+
+const tick = (
+ overlay: HTMLDivElement,
+ dialog: HTMLDivElement,
+ prev: HTMLElement | null,
+ deltaX: number
+) => {
+ const limitedDeltaX = Math.min(overlay.clientWidth, Math.max(0, deltaX));
+ const percent = limitedDeltaX / overlay.clientWidth;
+ const opacity =
+ overlayOpacityRange[1] -
+ (overlayOpacityRange[1] - overlayOpacityRange[0]) * percent;
+ overlay.style.background = `rgba(0, 0, 0, ${opacity})`;
+ dialog.style.transform = `translateX(${limitedDeltaX}px)`;
+
+ const prevEl = prev ?? document.querySelector('#app');
+ if (prevEl) {
+ const range = [-80, 0];
+ const offset = range[0] + (range[1] - range[0]) * percent;
+ prevEl.style.transform = `translateX(${offset}px)`;
+ }
+};
+const reset = (
+ overlay: HTMLDivElement,
+ dialog: HTMLDivElement,
+ prev: HTMLElement | null
+) => {
+ overlay && (overlay.style.background = 'transparent');
+ dialog && (dialog.style.transform = 'unset');
+ const prevEl = prev ?? document.querySelector('#app');
+ if (prevEl) {
+ prevEl.style.transform = 'unset';
+ }
+};
+
+const getAnimeProxy = (
+ overlay: HTMLDivElement,
+ dialog: HTMLDivElement,
+ prev: HTMLElement | null,
+ init: number
+) => {
+ return new Proxy(
+ { deltaX: init },
+ {
+ set(target, key, value) {
+ if (key === 'deltaX') {
+ target.deltaX = value;
+ tick(overlay, dialog, prev, value);
+ return true;
+ }
+ return false;
+ },
+ }
+ );
+};
+
+const cancel = (
+ overlay: HTMLDivElement,
+ dialog: HTMLDivElement,
+ prev: HTMLElement | null,
+ deltaX: number,
+ complete?: () => void
+) => {
+ anime({
+ targets: getAnimeProxy(
+ overlay,
+ dialog,
+ prev,
+ Math.min(overlay.clientWidth, Math.max(0, deltaX))
+ ),
+ deltaX: 0,
+ easing: 'easeInOutSine',
+ duration: 230,
+ complete: () => {
+ complete?.();
+ setTimeout(() => {
+ reset(overlay, dialog, prev);
+ }, 0);
+ },
+ });
+};
+
+const close = (
+ overlay: HTMLDivElement,
+ dialog: HTMLDivElement,
+ prev: HTMLElement | null,
+ deltaX: number,
+ complete?: () => void
+) => {
+ anime({
+ targets: getAnimeProxy(
+ overlay,
+ dialog,
+ prev,
+ Math.min(overlay.clientWidth, Math.max(0, deltaX))
+ ),
+ deltaX: overlay.clientWidth,
+ easing: 'easeInOutSine',
+ duration: 230,
+ complete: () => {
+ complete?.();
+ setTimeout(() => {
+ reset(overlay, dialog, prev);
+ }, 0);
+ },
+ });
+};
+
+const SwipeDialogContext = createContext<{
+ stack: Array>;
+}>({
+ stack: [],
+});
+
+export const SwipeDialog = ({
+ title,
+ children,
+ open,
+ triggerSize = 10,
+ onOpenChange,
+}: SwipeDialogProps) => {
+ const swiperTriggerRef = useRef(null);
+ const overlayRef = useRef(null);
+ const dialogRef = useRef(null);
+
+ const { stack } = useContext(SwipeDialogContext);
+ const prev = stack[stack.length - 1]?.current;
+
+ const handleClose = useCallback(() => {
+ onOpenChange?.(false);
+ }, [onOpenChange]);
+
+ const animateClose = useCallback(() => {
+ const overlay = overlayRef.current;
+ const dialog = dialogRef.current;
+ if (overlay && dialog) {
+ close(overlay, dialog, prev, 0, handleClose);
+ } else {
+ handleClose();
+ }
+ }, [handleClose, prev]);
+
+ useEffect(() => {
+ if (!open) return;
+ const overlay = overlayRef.current;
+ const dialog = dialogRef.current;
+ const swipeBackTrigger = swiperTriggerRef.current;
+ if (!overlay || !dialog || !swipeBackTrigger) return;
+
+ const swipeHelper = new SwipeHelper();
+ return swipeHelper.init(swipeBackTrigger, {
+ preventScroll: true,
+ onSwipeStart: () => {},
+ onSwipe({ deltaX }) {
+ tick(overlay, dialog, prev, deltaX);
+ },
+ onSwipeEnd: ({ deltaX }) => {
+ const shouldClose = deltaX > overlay.clientWidth * 0.2;
+ if (shouldClose) {
+ close(overlay, dialog, prev, deltaX, handleClose);
+ } else {
+ cancel(overlay, dialog, prev, deltaX);
+ }
+ },
+ });
+ }, [handleClose, open, prev]);
+
+ useEffect(() => {
+ if (!open) return;
+ const overlay = overlayRef.current;
+ const dialog = dialogRef.current;
+ if (overlay && dialog) {
+ cancel(overlay, dialog, prev, overlay.clientWidth);
+ }
+ }, [open, prev]);
+
+ if (!open) return null;
+
+ return (
+
+ {createPortal(
+
+
+
+
+
+ {title}
+
+
+
+ {children}
+
+
+
+
+
+
,
+ document.body
+ )}
+
+ );
+};