From af69154f1ca7bbf6bfa9e9cc485f3eb7abf4852c Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Wed, 23 Apr 2025 07:57:22 +0000 Subject: [PATCH] feat(core): sub-page for setting panel (#11678) **setting panel sub-page impl, with cascading pages support.** ## Usage ```tsx // inside setting content const island = useSubPageIsland(); const [open, setOpen] = useState(false); if (!island) { return null; } return ( setOpen(false)} backText="Back" /> ); ``` ### Preview ![CleanShot 2025-04-14 at 16.56.30.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/c042300d-c442-4708-a07a-54cd9f044abf.gif) --- .../src/desktop/dialogs/setting/index.tsx | 142 +++++++---- .../desktop/dialogs/setting/sub-page.css.ts | 55 ++++ .../src/desktop/dialogs/setting/sub-page.tsx | 238 ++++++++++++++++++ .../workspace-setting/integration/card.css.ts | 12 +- .../workspace-setting/integration/card.tsx | 40 +-- .../integration/constants.tsx | 23 ++ .../integration/index.css.ts | 2 +- .../workspace-setting/integration/index.tsx | 63 ++++- .../integration/readwise/connect.tsx | 33 ++- .../integration/readwise/connected.tsx | 16 +- .../integration/readwise/index.css.ts | 6 +- .../integration/readwise/index.tsx | 85 ------- ...ing-dialog.css.ts => setting-panel.css.ts} | 26 +- .../{setting-dialog.tsx => setting-panel.tsx} | 95 ++++--- .../integration/setting.css.ts | 31 +++ .../workspace-setting/integration/setting.tsx | 26 ++ .../core/src/modules/feature-flag/constant.ts | 7 + packages/frontend/core/src/utils/island.tsx | 11 +- 18 files changed, 659 insertions(+), 252 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/sub-page.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/sub-page.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx delete mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/index.tsx rename packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/{setting-dialog.css.ts => setting-panel.css.ts} (77%) rename packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/{setting-dialog.tsx => setting-panel.tsx} (80%) diff --git a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx index 3e82527705..16162985a9 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/index.tsx @@ -13,6 +13,7 @@ import type { WORKSPACE_DIALOG_SCHEMA, } from '@affine/core/modules/dialogs/constant'; import { GlobalContextService } from '@affine/core/modules/global-context'; +import { createIsland, type Island } from '@affine/core/utils/island'; import { ServerDeploymentType } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { ContactWithUsIcon } from '@blocksuite/icons/rc'; @@ -23,6 +24,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from 'react'; @@ -34,6 +36,11 @@ import { IssueFeedbackModal } from './issue-feedback-modal'; import { SettingSidebar } from './setting-sidebar'; import { StarAFFiNEModal } from './star-affine-modal'; import * as style from './style.css'; +import { + SubPageContext, + type SubPageContextType, + SubPageTarget, +} from './sub-page'; import type { SettingState } from './types'; import { WorkspaceSetting } from './workspace-setting'; @@ -59,6 +66,7 @@ const SettingModalInner = ({ onCloseSetting, scrollAnchor: initialScrollAnchor, }: SettingProps) => { + const [subPageIslands, setSubPageIslands] = useState([]); const [settingState, setSettingState] = useState({ activeTab: initialActiveTab, scrollAnchor: initialScrollAnchor, @@ -143,6 +151,24 @@ const SettingModalInner = ({ setOpenStarAFFiNEModal(true); }, [setOpenStarAFFiNEModal]); + const addSubPageIsland = useCallback(() => { + const island = createIsland(); + setSubPageIslands(prev => [...prev, island]); + const dispose = () => { + setSubPageIslands(prev => prev.filter(i => i !== island)); + }; + return { island, dispose }; + }, []); + + const contextValue = useMemo( + () => + ({ + islands: subPageIslands, + addIsland: addSubPageIsland, + }) satisfies SubPageContextType, + [subPageIslands, addSubPageIsland] + ); + useEffect(() => { if ( isSelfhosted && @@ -171,64 +197,69 @@ const SettingModalInner = ({ activeTab={settingState.activeTab} onTabChange={onTabChange} /> - - -
-
- }> - {settingState.activeTab === 'account' && - loginStatus === 'authenticated' ? ( - - ) : isWorkspaceSetting(settingState.activeTab) ? ( - - ) : !isWorkspaceSetting(settingState.activeTab) ? ( - - ) : null} - -
-
- - + + +
+
+ }> + {settingState.activeTab === 'account' && + loginStatus === 'authenticated' ? ( + + ) : isWorkspaceSetting(settingState.activeTab) ? ( + - ), - 2: ( - - ), - }} + ) : null} + +
+
+ + + ), + 2: ( + + ), + }} + /> +
+ +
- - -
- - - + + + + + ); }; @@ -254,6 +285,9 @@ export const SettingDialog = ({ }} open onOpenChange={() => close()} + closeButtonOptions={{ + style: { right: 14, top: 14 }, + }} > }> { + island: Island; + dispose: () => void; + }; +} +export const SubPageContext = createContext({ + islands: [], + addIsland: () => ({ + // must be initialized + island: null as unknown as Island, + dispose: () => {}, + }), +}); + +const SubPageTargetItem = ({ island }: { island: Island }) => { + const provided = useLiveData(island.provided$); + + return ( + + ); +}; +export const SubPageTarget = () => { + const context = useContext(SubPageContext); + const islands = context.islands; + return islands.map(island => ( + + )); +}; + +const ease = eases.cubicBezier(0.25, 0.36, 0.24, 0.97); + +export const SubPageProvider = ({ + island, + open, + onClose, + children, + backText = 'Back', + animation = true, +}: { + island: Island; + open: boolean; + onClose: () => void; + children: React.ReactNode; + backText?: string; + animation?: boolean; +}) => { + const featureFlagService = useService(FeatureFlagService); + const enableSettingSubpageAnimation = useLiveData( + featureFlagService.flags.enable_setting_subpage_animation.$ + ); + const duration = enableSettingSubpageAnimation ? (animation ? 320 : 0) : 0; + + const maskRef = useRef(null); + const pageRef = useRef(null); + + const [innerOpen, setInnerOpen] = useState(open); + const [animateState, setAnimateState] = useState< + 'idle' | 'ready' | 'animating' | 'finished' + >('idle'); + const [played, setPlayed] = useState(false); + + const prevPageRef = useRef(null); + + const getPrevPage = useCallback((_root?: HTMLDivElement) => { + const root = _root ?? pageRef.current?.parentElement; + if (!root) { + return null; + } + const prevPage = root.previousElementSibling as HTMLDivElement; + if (!prevPage) { + return null; + } + + if (prevPage.dataset.settingPage && prevPage.dataset.open === 'true') { + prevPageRef.current = prevPage; + return prevPage; + } + return getPrevPage(prevPage); + }, []); + + const animateOpen = useCallback(() => { + setAnimateState('animating'); + requestAnimationFrame(() => { + const mask = maskRef.current; + const page = pageRef.current; + if (!mask || !page) { + setAnimateState('idle'); + return; + } + waapi.animate(mask, { opacity: [0, 1], duration, ease }); + waapi + .animate(page, { x: ['100%', 0], duration, ease }) + .then(() => setAnimateState('finished')) + .catch(console.error) + .finally(() => { + setAnimateState('finished'); + setPlayed(true); + }); + + const prevPage = getPrevPage(); + if (!prevPage) return; + waapi.animate(prevPage, { x: [0, '-20%'], duration, ease }); + }); + }, [duration, getPrevPage]); + + const animateClose = useCallback(() => { + setAnimateState('animating'); + requestAnimationFrame(() => { + const mask = maskRef.current; + const page = pageRef.current; + if (!mask || !page) { + setAnimateState('idle'); + return; + } + waapi.animate(mask, { opacity: [1, 0], duration, ease }); + waapi + .animate(page, { x: [0, '100%'], duration, ease }) + .then(() => setAnimateState('idle')) + .catch(console.error) + .finally(() => setAnimateState('idle')); + + const prevPage = getPrevPage(); + if (!prevPage) return; + waapi.animate(prevPage, { x: ['-20%', 0], duration, ease }); + }); + }, [duration, getPrevPage]); + + useEffect(() => { + setAnimateState('ready'); + setInnerOpen(open); + }, [open]); + + useEffect(() => { + if (animateState !== 'ready') return; + + if (innerOpen) { + animateOpen(); + return; + } + // the first played animation must be open + if (!played) { + setAnimateState('idle'); + return; + } + animateClose(); + }, [animateClose, animateOpen, animateState, innerOpen, played]); + + /** + * for some situation like: + * + * ```tsx + * const [open, setOpen] = useState(false); + * if (!open) return null; + * return setOpen(false)} /> + * ``` + * + * The subpage is closed unexpectedly, so we need to reset the previous page's position. + */ + useEffect(() => { + return () => { + const prevPage = prevPageRef.current; + if (!prevPage) return; + waapi.animate(prevPage, { x: 0, duration: 0 }); + }; + }, []); + + if (animateState === 'idle') { + return null; + } + + return ( + +
+
+
+ +
+ + +
+
{children}
+
+ +
+
+
+
+ ); +}; + +/** + * Create a new island when the component is mounted, + * and dispose it when the component is unmounted. + */ +export const useSubPageIsland = () => { + const { addIsland } = useContext(SubPageContext); + const [island, setIsland] = useState(null); + + useEffect(() => { + const { island, dispose } = addIsland(); + setIsland(island); + return dispose; + }, [addIsland]); + + return island; +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts index aab84c008b..7728b2259d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.css.ts @@ -8,10 +8,11 @@ export const card = style({ padding: '8px 12px 12px 12px', borderRadius: 8, border: '1px solid ' + cssVarV2.layer.insideBorder.border, - height: 186, + height: 150, display: 'flex', flexDirection: 'column', background: cssVarV2.layer.background.overlayPanel, + cursor: 'pointer', }); export const cardHeader = style({ display: 'flex', @@ -48,6 +49,12 @@ export const cardTitle = style({ lineHeight: '22px', color: cssVarV2.text.primary, }); +export const cardStatus = style({ + fontSize: 12, + lineHeight: '20px', + fontWeight: 400, + color: cssVarV2.text.secondary, +}); export const cardDesc = style([ spaceY, { @@ -69,6 +76,3 @@ export const cardFooter = style({ }, }, }); -export const settingIcon = style({ - color: cssVarV2.icon.secondary, -}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx index 6bfd7098c5..20a7d9194c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/card.tsx @@ -1,5 +1,3 @@ -import { IconButton, type IconButtonProps } from '@affine/component'; -import { SettingsIcon } from '@blocksuite/icons/rc'; import clsx from 'clsx'; import type { HTMLAttributes, ReactNode } from 'react'; @@ -10,16 +8,15 @@ import { cardFooter, cardHeader, cardIcon, + cardStatus, cardTitle, - settingIcon, } from './card.css'; -import { spaceX } from './index.css'; export const IntegrationCard = ({ className, ...props -}: HTMLAttributes) => { - return
  • ; +}: HTMLAttributes) => { + return
    ; }; export const IntegrationCardIcon = ({ @@ -29,52 +26,37 @@ export const IntegrationCardIcon = ({ return
    ; }; -export const IntegrationSettingIcon = ({ - className, - ...props -}: IconButtonProps) => { - return ( - } - variant="plain" - {...props} - /> - ); -}; - export const IntegrationCardHeader = ({ className, icon, - onSettingClick, - showSetting = true, + title, + status, ...props }: HTMLAttributes & { - showSetting?: boolean; - onSettingClick?: () => void; icon?: ReactNode; + title?: string; + status?: ReactNode; }) => { return (
    {icon} -
    - {showSetting ? : null} +
    +
    {title}
    + {status ?
    {status}
    : null} +
    ); }; export const IntegrationCardContent = ({ className, - title, desc, ...props }: HTMLAttributes & { - title?: string; desc?: string; }) => { return (
    -
    {title}
    {desc}
    ); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx new file mode 100644 index 0000000000..578b46712d --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/constants.tsx @@ -0,0 +1,23 @@ +import { IntegrationTypeIcon } from '@affine/core/modules/integration'; +import type { I18nString } from '@affine/i18n'; +import type { ReactNode } from 'react'; + +import { ReadwiseSettingPanel } from './readwise/setting-panel'; + +export type IntegrationCard = { + id: string; + name: I18nString; + desc: I18nString; + icon: ReactNode; + setting: ReactNode; +}; + +export const INTEGRATION_LIST: IntegrationCard[] = [ + { + id: 'readwise', + name: 'com.affine.integration.readwise.name', + desc: 'com.affine.integration.readwise.desc', + icon: , + setting: , + }, +]; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts index f55fc60bbb..2c19225aea 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.css.ts @@ -10,6 +10,6 @@ export const spaceY = style({ }); export const list = style({ display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', + gridTemplateColumns: 'repeat(auto-fill, minmax(175px, 1fr))', gap: '16px', }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx index ebe832281d..355dd2f175 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/index.tsx @@ -1,11 +1,19 @@ import { SettingHeader } from '@affine/component/setting-components'; import { useI18n } from '@affine/i18n'; +import { type ReactNode, useState } from 'react'; +import { SubPageProvider, useSubPageIsland } from '../../sub-page'; +import { + IntegrationCard, + IntegrationCardContent, + IntegrationCardHeader, +} from './card'; +import { INTEGRATION_LIST } from './constants'; import { list } from './index.css'; -import { ReadwiseIntegration } from './readwise'; export const IntegrationSetting = () => { const t = useI18n(); + const [opened, setOpened] = useState(null); return ( <> { } />
      - + {INTEGRATION_LIST.map(item => { + const title = + typeof item.name === 'string' + ? t[item.name]() + : t[item.name.i18nKey](); + const desc = + typeof item.desc === 'string' + ? t[item.desc]() + : t[item.desc.i18nKey](); + return ( +
    • + setOpened(item.id)}> + + + + setOpened(null)} + > + {item.setting} + +
    • + ); + })}
    ); }; + +const IntegrationSettingPage = ({ + children, + open, + onClose, +}: { + children: ReactNode; + open: boolean; + onClose: () => void; +}) => { + const t = useI18n(); + const island = useSubPageIsland(); + + if (!island) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx index 9039ad1b7b..54181ca3dd 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connect.tsx @@ -1,11 +1,19 @@ -import { Button, Input, Modal, notify } from '@affine/component'; +import { + Button, + type ButtonProps, + Input, + Modal, + notify, +} from '@affine/component'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { IntegrationService } from '@affine/core/modules/integration'; import { Trans, useI18n } from '@affine/i18n'; import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; +import clsx from 'clsx'; import { type FormEvent, + type MouseEvent, useCallback, useEffect, useRef, @@ -156,11 +164,14 @@ const ConnectDialog = ({ ); }; -export const ConnectButton = ({ +export const ReadwiseConnectButton = ({ onSuccess, + className, + onClick, + ...buttonProps }: { onSuccess: (token: string) => void; -}) => { +} & ButtonProps) => { const t = useI18n(); const [open, setOpen] = useState(false); @@ -168,14 +179,22 @@ export const ConnectButton = ({ setOpen(false); }, []); - const handleOpen = useCallback(() => { - setOpen(true); - }, []); + const handleOpen = useCallback( + (e: MouseEvent) => { + onClick?.(e); + setOpen(true); + }, + [onClick] + ); return ( <> {open && } - diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx index bca2822039..959ee23925 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/integration/readwise/connected.tsx @@ -9,7 +9,11 @@ import * as styles from './connected.css'; import { actionButton } from './index.css'; import { readwiseTrack } from './track'; -export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => { +export const ReadwiseDisconnectDialog = ({ + onClose, +}: { + onClose: () => void; +}) => { const t = useI18n(); const readwise = useService(IntegrationService).readwise; @@ -59,18 +63,16 @@ export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => { ); }; -export const ConnectedActions = ({ onImport }: { onImport: () => void }) => { +export const ReadwiseDisconnectButton = () => { const t = useI18n(); const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); - return ( <> {showDisconnectDialog && ( - setShowDisconnectDialog(false)} /> + setShowDisconnectDialog(false)} + /> )} -