diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index ebf26872da..c757716e98 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toolbar": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 2679dc390e..f93554d6f5 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -24,6 +24,7 @@ export * from './ui/scrollbar'; export * from './ui/skeleton'; export * from './ui/switch'; export * from './ui/table'; +export * from './ui/tabs'; export * from './ui/toast'; export * from './ui/tooltip'; export * from './utils'; diff --git a/packages/frontend/component/src/ui/tabs/index.ts b/packages/frontend/component/src/ui/tabs/index.ts new file mode 100644 index 0000000000..c2d1b4e91b --- /dev/null +++ b/packages/frontend/component/src/ui/tabs/index.ts @@ -0,0 +1 @@ +export * from './tabs'; diff --git a/packages/frontend/component/src/ui/tabs/tabs.css.ts b/packages/frontend/component/src/ui/tabs/tabs.css.ts new file mode 100644 index 0000000000..c76b3ee947 --- /dev/null +++ b/packages/frontend/component/src/ui/tabs/tabs.css.ts @@ -0,0 +1,52 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const tabsRoot = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + gap: '8px', +}); + +export const tabsList = style({ + display: 'flex', + gap: '12px', + boxSizing: 'border-box', + position: 'relative', + '::after': { + content: '""', + position: 'absolute', + bottom: '0px', + width: '100%', + height: '1px', + backgroundColor: cssVarV2('layer/border'), + }, +}); + +export const tabsTrigger = style({ + all: 'unset', + fontWeight: 500, + padding: '6px 4px', + cursor: 'pointer', + fontSize: cssVar('fontSm'), + color: cssVarV2('text/secondary'), + borderBottom: '2px solid transparent', + selectors: { + '&[data-state="active"]': { + color: cssVarV2('text/primary'), + borderColor: cssVarV2('button/primary'), + }, + }, +}); + +export const tabsContent = style({ + display: 'flex', + flexDirection: 'column', + + selectors: { + '&[data-state="inactive"]': { + display: 'none', + }, + }, +}); diff --git a/packages/frontend/component/src/ui/tabs/tabs.tsx b/packages/frontend/component/src/ui/tabs/tabs.tsx new file mode 100644 index 0000000000..3c8b0ba1aa --- /dev/null +++ b/packages/frontend/component/src/ui/tabs/tabs.tsx @@ -0,0 +1,80 @@ +import * as TabsGroup from '@radix-ui/react-tabs'; +import clsx from 'clsx'; +import { forwardRef, type RefAttributes } from 'react'; + +import * as styles from './tabs.css'; + +export const TabsRoot = forwardRef< + HTMLDivElement, + TabsGroup.TabsProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +TabsRoot.displayName = 'TabsRoot'; + +export const TabsList = forwardRef< + HTMLDivElement, + TabsGroup.TabsListProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +TabsList.displayName = 'TabsList'; + +export const TabsTrigger = forwardRef< + HTMLButtonElement, + TabsGroup.TabsTriggerProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +TabsTrigger.displayName = 'TabsTrigger'; + +export const TabsContent = forwardRef< + HTMLDivElement, + TabsGroup.TabsContentProps & RefAttributes +>(({ children, className, ...props }, ref) => { + return ( + + {children} + + ); +}); + +TabsContent.displayName = 'TabsContent'; + +export const Tabs = { + Root: TabsRoot, + List: TabsList, + Trigger: TabsTrigger, + Content: TabsContent, +}; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts index 86ad7d0c7e..9cab572c91 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { globalStyle, style } from '@vanilla-extract/css'; export const headerStyle = style({ display: 'flex', @@ -9,34 +10,133 @@ export const headerStyle = style({ padding: '0 4px', gap: '4px', }); +export const content = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); export const menuStyle = style({ - width: '410px', + width: '390px', height: 'auto', padding: '12px', - transform: 'translateX(-10px)', +}); +export const menuTriggerStyle = style({ + width: '150px', + padding: '4px 10px', + justifyContent: 'space-between', }); export const menuItemStyle = style({ padding: '4px', +}); +export const publicItemRowStyle = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +}); +export const publicMenuItemPrefixStyle = style({ + fontSize: cssVar('fontH5'), + color: cssVarV2('icon/primary'), + marginRight: '8px', +}); +export const DoneIconStyle = style({ + color: cssVarV2('button/primary'), + fontSize: cssVar('fontH5'), + marginLeft: '8px', +}); +export const exportItemStyle = style({ + padding: '4px', + transition: 'all 0.3s', +}); +export const copyLinkContainerStyle = style({ + padding: '4px', + display: 'flex', + alignItems: 'center', + width: '100%', + position: 'relative', +}); +export const copyLinkButtonStyle = style({ + flex: 1, + padding: '4px 12px', + paddingRight: '6px', + borderRight: 'none', + borderTopRightRadius: '0', + borderBottomRightRadius: '0', + color: 'transparent', + position: 'initial', +}); +export const copyLinkLabelContainerStyle = style({ + width: '100%', + padding: '4px 12px', + borderRight: 'none', + borderTopRightRadius: '0', + borderBottomRightRadius: '0', + position: 'relative', +}); +export const copyLinkLabelStyle = style({ + position: 'absolute', + textAlign: 'end', + top: '50%', + left: '50%', + transform: 'translateX(-50%) translateY(-50%)', + lineHeight: '20px', + color: cssVarV2('text/pureWhite'), +}); +export const copyLinkShortcutStyle = style({ + position: 'absolute', + textAlign: 'end', + top: '50%', + right: '52px', + transform: 'translateY(-50%)', + opacity: 0.5, + lineHeight: '20px', + color: cssVarV2('text/pureWhite'), +}); +export const copyLinkTriggerStyle = style({ + width: '100%', + padding: '4px 12px', + paddingLeft: '4px', + display: 'flex', + border: `1px solid ${cssVarV2('button/innerBlackBorder')}`, + flex: 0, + justifyContent: 'end', + alignItems: 'center', + gap: '4px', + position: 'relative', + backgroundColor: cssVarV2('button/primary'), + color: cssVarV2('button/pureWhiteText'), + borderLeft: 'none', + borderTopLeftRadius: '0', + borderBottomLeftRadius: '0', + ':hover': { + backgroundColor: cssVarV2('button/primary'), + color: cssVarV2('button/pureWhiteText'), + }, + '::before': { + content: '""', + position: 'absolute', + left: '0', + top: '0', + height: '100%', + width: '1px', + backgroundColor: cssVarV2('button/innerBlackBorder'), + }, +}); +globalStyle(`${copyLinkTriggerStyle} svg`, { + color: cssVarV2('button/pureWhiteText'), + transform: 'translateX(2px)', +}); +export const copyLinkMenuItemStyle = style({ + padding: '4px', transition: 'all 0.3s', }); export const descriptionStyle = style({ wordWrap: 'break-word', fontSize: cssVar('fontXs'), lineHeight: '20px', - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), textAlign: 'left', padding: '0 6px', }); -export const buttonStyle = style({ - marginTop: '18px', -}); -export const actionsStyle = style({ - display: 'flex', - gap: '9px', - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'flex-start', -}); export const containerStyle = style({ display: 'flex', width: '100%', @@ -46,25 +146,13 @@ export const containerStyle = style({ export const indicatorContainerStyle = style({ position: 'relative', }); -export const inputButtonRowStyle = style({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - marginTop: '16px', -}); export const titleContainerStyle = style({ display: 'flex', alignItems: 'center', - gap: '4px', - fontSize: cssVar('fontSm'), - fontWeight: 500, - lineHeight: '22px', - padding: '0 4px', -}); -export const subTitleStyle = style({ - fontSize: cssVar('fontSm'), - fontWeight: 500, - lineHeight: '22px', + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), + fontWeight: 400, + padding: '8px 4px 0 4px', }); export const columnContainerStyle = style({ display: 'flex', @@ -78,33 +166,21 @@ export const rowContainerStyle = style({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - gap: '12px', padding: '4px', }); -export const radioButtonGroup = style({ - display: 'flex', - justifyContent: 'flex-end', - padding: '2px', - minWidth: '154px', - maxWidth: '250px', -}); -export const radioButton = style({ - color: cssVar('textSecondaryColor'), - selectors: { - '&[data-state="checked"]': { - color: cssVar('textPrimaryColor'), - }, - }, +export const labelStyle = style({ + fontSize: cssVar('fontSm'), + fontWeight: 500, }); export const disableSharePage = style({ - color: cssVar('errorColor'), + color: cssVarV2('button/error'), }); export const localSharePage = style({ padding: '12px 8px', display: 'flex', alignItems: 'center', borderRadius: '8px', - backgroundColor: cssVar('backgroundSecondaryColor'), + backgroundColor: cssVarV2('layer/background/secondary'), minHeight: '84px', position: 'relative', }); @@ -117,12 +193,6 @@ export const cloudSvgContainer = style({ bottom: '0', right: '0', }); -export const shareIconStyle = style({ - fontSize: '16px', - color: cssVar('iconColor'), - display: 'flex', - alignItems: 'center', -}); export const shareLinkStyle = style({ padding: '4px', fontSize: cssVar('fontXs'), @@ -132,17 +202,38 @@ export const shareLinkStyle = style({ gap: '4px', }); globalStyle(`${shareLinkStyle} > span`, { - color: cssVar('linkColor'), + color: cssVarV2('text/link'), }); globalStyle(`${shareLinkStyle} > div > svg`, { - color: cssVar('linkColor'), + color: cssVarV2('text/link'), }); -export const shareButton = style({ +export const buttonContainer = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', + fontWeight: 500, +}); +export const button = style({ + padding: '6px 8px', height: 32, - padding: '0px 8px', }); export const shortcutStyle = style({ fontSize: cssVar('fontXs'), - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), fontWeight: 400, }); +export const openWorkspaceSettingsStyle = style({ + color: cssVarV2('text/link'), + fontSize: cssVar('fontXs'), + fontWeight: 500, + display: 'flex', + gap: '8px', + alignItems: 'center', + justifyContent: 'flex-start', + width: '100%', + padding: '4px', + cursor: 'pointer', +}); +globalStyle(`${openWorkspaceSettingsStyle} svg`, { + color: cssVarV2('text/link'), +}); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx index 89a1cf1583..1967a1ecf2 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx @@ -1,80 +1,30 @@ -import { MenuIcon, MenuItem } from '@affine/component'; -import { Divider } from '@affine/component/ui/divider'; import { ExportMenuItems } from '@affine/core/components/page-list'; import { useExportPage } from '@affine/core/hooks/affine/use-export-page'; -import { useSharingUrl } from '@affine/core/hooks/affine/use-share-url'; import { EditorService } from '@affine/core/modules/editor'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useI18n } from '@affine/i18n'; -import { CopyIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import * as styles from './index.css'; import type { ShareMenuProps } from './share-menu'; -export const ShareExport = ({ - workspaceMetadata: workspace, - currentPage, -}: ShareMenuProps) => { +export const ShareExport = ({ currentPage }: ShareMenuProps) => { const t = useI18n(); const editor = useService(EditorService).editor; - const workspaceId = workspace.id; - const pageId = currentPage.id; - const { sharingUrl, onClickCopyLink } = useSharingUrl({ - workspaceId, - pageId, - urlType: 'workspace', - }); const exportHandler = useExportPage(currentPage); const currentMode = useLiveData(editor.mode$); - const isMac = environment.isBrowser && environment.isMacOs; return ( <> -
- {t['com.affine.share-menu.ShareViaExport']()} -
{t['com.affine.share-menu.ShareViaExportDescription']()}
- {workspace.flavour !== WorkspaceFlavour.LOCAL ? ( -
- -
- {t['com.affine.share-menu.share-privately']()} -
-
- {t['com.affine.share-menu.share-privately.description']()} -
-
- - - - } - endFix={ -
- {isMac ? '⌘ + ⌥ + C' : 'Ctrl + Shift + C'} -
- } - > - {t['com.affine.share-menu.copy-private-link']()} -
-
-
- ) : null} ); }; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx index 897958bd89..c0ecb70f38 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx @@ -1,10 +1,10 @@ +import { Tabs, Tooltip } from '@affine/component'; import { Button } from '@affine/component/ui/button'; -import { Divider } from '@affine/component/ui/divider'; import { Menu } from '@affine/component/ui/menu'; import { ShareInfoService } from '@affine/core/modules/share-doc'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useI18n } from '@affine/i18n'; -import { WebIcon } from '@blocksuite/icons/rc'; +import { LockIcon, PublishIcon } from '@blocksuite/icons/rc'; import type { Doc } from '@blocksuite/store'; import { useLiveData, @@ -28,17 +28,20 @@ export const ShareMenuContent = (props: ShareMenuProps) => { const t = useI18n(); return (
-
-
- -
- {t['com.affine.share-menu.SharePage']()} -
- -
- -
- + + + + {t['com.affine.share-menu.shareButton']()} + + {t['Export']()} + + + + + + + +
); }; @@ -56,11 +59,20 @@ const DefaultShareButton = forwardRef(function DefaultShareButton( }, [shareInfoService]); return ( - + + + ); }); @@ -71,6 +83,7 @@ const LocalShareMenu = (props: ShareMenuProps) => { contentOptions={{ className: styles.menuStyle, ['data-testid' as string]: 'local-share-menu', + align: 'end', }} rootOptions={{ modal: false, @@ -91,6 +104,7 @@ const CloudShareMenu = (props: ShareMenuProps) => { contentOptions={{ className: styles.menuStyle, ['data-testid' as string]: 'cloud-share-menu', + align: 'end', }} rootOptions={{ modal: false, diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx index 3f66ae129f..4c4e8dbe59 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx @@ -1,27 +1,32 @@ -import { Input, notify, RadioGroup, Skeleton, Switch } from '@affine/component'; +import { notify, Skeleton } from '@affine/component'; import { PublicLinkDisableModal } from '@affine/component/disable-public-link'; import { Button } from '@affine/component/ui/button'; import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu'; +import { openSettingModalAtom } from '@affine/core/atoms'; import { useSharingUrl } from '@affine/core/hooks/affine/use-share-url'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { track } from '@affine/core/mixpanel'; import { ServerConfigService } from '@affine/core/modules/cloud'; +import { EditorService } from '@affine/core/modules/editor'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { ShareInfoService } from '@affine/core/modules/share-doc'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { PublicPageMode } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { - ArrowRightSmallIcon, + CollaborationIcon, + DoneIcon, + EdgelessIcon, + LinkIcon, + LockIcon, + PageIcon, SingleSelectSelectSolidIcon, + ViewIcon, } from '@blocksuite/icons/rc'; -import { - type DocMode, - DocService, - useLiveData, - useService, -} from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; import { cssVar } from '@toeverything/theme'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { useSetAtom } from 'jotai'; +import { Suspense, useCallback, useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { CloudSvg } from '../cloud-svg'; @@ -54,11 +59,12 @@ export const LocalSharePage = (props: ShareMenuProps) => { ); }; -export const AffineSharePage = (props: ShareMenuProps) => { +export const AFFiNESharePage = (props: ShareMenuProps) => { + const t = useI18n(); const { workspaceMetadata: { id: workspaceId }, } = props; - const doc = useService(DocService).doc; + const editor = useService(EditorService).editor; const shareInfoService = useService(ShareInfoService); const serverConfig = useService(ServerConfigService).serverConfig; useEffect(() => { @@ -71,44 +77,28 @@ export const AffineSharePage = (props: ShareMenuProps) => { isSharedPage === null || sharedMode === null || baseUrl === null; const [showDisable, setShowDisable] = useState(false); - const currentDocMode = useLiveData(doc.primaryMode$); + const currentDocMode = useLiveData(editor.mode$); - const mode = useMemo(() => { - if (isSharedPage && sharedMode) { - // if it's a shared page, use the share mode - return sharedMode.toLowerCase() as DocMode; + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + const setSettingModalAtom = useSetAtom(openSettingModalAtom); + + const onOpenWorkspaceSettings = useCallback(() => { + setSettingModalAtom({ + open: true, + activeTab: 'workspace:preference', + workspaceMetadata: props.workspaceMetadata, + }); + }, [props.workspaceMetadata, setSettingModalAtom]); + + const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => { + if (isSharedPage) { + return; } - // default to page mode - return currentDocMode; - }, [currentDocMode, isSharedPage, sharedMode]); - - const { sharingUrl, onClickCopyLink } = useSharingUrl({ - workspaceId, - pageId: doc.id, - urlType: 'share', - }); - - const t = useI18n(); - - const modeOptions = useMemo( - () => [ - { value: 'page', label: t['com.affine.pageMode.page']() }, - { - value: 'edgeless', - label: t['com.affine.pageMode.edgeless'](), - }, - ], - [t] - ); - - const onClickCreateLink = useAsyncCallback(async () => { try { - await shareInfoService.shareInfo.enableShare( - mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page - ); - track.$.sharePanel.$.createShareLink({ - mode, - }); + // TODO(@JimmFly): remove mode when we have a better way to handle it + await shareInfoService.shareInfo.enableShare(PublicPageMode.Page); + track.$.sharePanel.$.createShareLink(); notify.success({ title: t[ @@ -121,11 +111,6 @@ export const AffineSharePage = (props: ShareMenuProps) => { style: 'normal', icon: , }); - if (sharingUrl) { - navigator.clipboard.writeText(sharingUrl).catch(err => { - console.error(err); - }); - } } catch (err) { notify.error({ title: @@ -139,7 +124,7 @@ export const AffineSharePage = (props: ShareMenuProps) => { }); console.error(err); } - }, [mode, shareInfoService.shareInfo, sharingUrl, t]); + }, [isSharedPage, shareInfoService.shareInfo, t]); const onDisablePublic = useAsyncCallback(async () => { try { @@ -170,46 +155,29 @@ export const AffineSharePage = (props: ShareMenuProps) => { setShowDisable(false); }, [shareInfoService, t]); - const onShareModeChange = useAsyncCallback( - async (value: DocMode) => { - try { - if (isSharedPage) { - await shareInfoService.shareInfo.changeShare( - value === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page - ); - notify.success({ - title: - t[ - 'com.affine.share-menu.confirm-modify-mode.notification.success.title' - ](), - message: t[ - 'com.affine.share-menu.confirm-modify-mode.notification.success.message' - ]({ - preMode: value === 'edgeless' ? t['Page']() : t['Edgeless'](), - currentMode: value === 'edgeless' ? t['Edgeless']() : t['Page'](), - }), - style: 'normal', - icon: ( - - ), - }); - } - } catch (err) { - notify.error({ - title: - t[ - 'com.affine.share-menu.confirm-modify-mode.notification.fail.title' - ](), - message: - t[ - 'com.affine.share-menu.confirm-modify-mode.notification.fail.message' - ](), - }); - console.error(err); - } - }, - [isSharedPage, shareInfoService.shareInfo, t] - ); + const onClickDisable = useCallback(() => { + if (isSharedPage) { + setShowDisable(true); + } + }, [isSharedPage]); + + const isMac = environment.isBrowser && environment.isMacOs; + + const { onClickCopyLink } = useSharingUrl({ + workspaceId, + pageId: editor.doc.id, + }); + + const onCopyPageLink = useCallback(() => { + onClickCopyLink('page'); + }, [onClickCopyLink]); + const onCopyEdgelessLink = useCallback(() => { + onClickCopyLink('edgeless'); + }, [onClickCopyLink]); + const onCopyBlockLink = useCallback(() => { + // TODO(@JimmFly): handle frame + onClickCopyLink(); + }, [onClickCopyLink]); if (isLoading) { // TODO(@eyhn): loading and error UI @@ -222,110 +190,175 @@ export const AffineSharePage = (props: ShareMenuProps) => { } return ( - <> +
- {t['com.affine.share-menu.publish-to-web']()} + {isSharedPage + ? t['com.affine.share-menu.option.link.readonly.description']() + : t['com.affine.share-menu.option.link.no-access.description']()}
-
- {t['com.affine.share-menu.publish-to-web.description']()} -
-
-
- - {isSharedPage ? ( - - ) : ( - - )} -
-
-
- {t['com.affine.share-menu.ShareMode']()} -
-
- -
-
- {isSharedPage ? ( - <> - {runtimeConfig.enableEnhanceShareMode && ( - <> -
-
Link expires
-
- Never}> - Never - -
-
-
-
- {'Show "Created with AFFiNE"'} -
-
- -
-
-
-
- Search engine indexing -
-
- -
-
- - )} - } - block - type="danger" - className={styles.menuItemStyle} - onSelect={e => { - e.preventDefault(); - setShowDisable(true); +
+
+ {t['com.affine.share-menu.option.link.label']()} +
+ + + } + onSelect={onClickDisable} + className={styles.menuItemStyle} + > +
+
+ {t['com.affine.share-menu.option.link.no-access']()} +
+ {!isSharedPage && ( + + )} +
+
+ + } + className={styles.menuItemStyle} + onSelect={onClickAnyoneReadOnlyShare} + data-testid="share-link-menu-enable-share" + > +
+
+ {t['com.affine.share-menu.option.link.readonly']()} +
+ {isSharedPage && ( + + )} +
+
+ + } > -
- {t['Disable Public Link']()} -
- - + {isSharedPage + ? t['com.affine.share-menu.option.link.readonly']() + : t['com.affine.share-menu.option.link.no-access']()} + +
+
+
+
+ {t['com.affine.share-menu.option.permission.label']()} +
+ + {t['com.affine.share-menu.option.permission.can-edit']()} + + } + > + + {t['com.affine.share-menu.option.permission.can-edit']()} + + +
+
+ {isOwner && ( +
+ + {t['com.affine.share-menu.navigate.workspace']()} +
+ )} +
+ + + + } + className={styles.menuItemStyle} + onSelect={onCopyPageLink} + data-testid="share-link-menu-copy-page" + > + {t['com.affine.share-menu.copy.page']()} + + + } + className={styles.menuItemStyle} + onSelect={onCopyEdgelessLink} + data-testid="share-link-menu-copy-edgeless" + > + {t['com.affine.share-menu.copy.edgeless']()} + + + } + className={styles.menuItemStyle} + onSelect={onCopyBlockLink} + > + {t['com.affine.share-menu.copy.block']()} + + {currentDocMode === 'edgeless' && ( + + } + className={styles.menuItemStyle} + onSelect={onCopyBlockLink} + > + {t['com.affine.share-menu.copy.frame']()} + + )} + + } + > + - - ) : null} - + +
+ + + ); }; @@ -339,7 +372,7 @@ export const SharePage = (props: ShareMenuProps) => { // TODO(@eyhn): refactor this part - + ); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 34fbe5849e..404b2963a0 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -48,6 +48,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ }: PageDetailEditorProps & { page: BlockSuiteDoc }) { const editor = useService(EditorService).editor; const mode = useLiveData(editor.mode$); + const isSharedMode = editor.isSharedMode; const { appSettings } = useAppSettingHelper(); diff --git a/packages/frontend/core/src/hooks/affine/use-register-copy-link-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-copy-link-commands.tsx index a40ccfec76..d0599f2f41 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-copy-link-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-copy-link-commands.tsx @@ -19,10 +19,10 @@ export function useRegisterCopyLinkCommands({ const isActiveView = useIsActiveView(); const workspaceId = workspaceMeta.id; const isCloud = workspaceMeta.flavour === WorkspaceFlavour.AFFINE_CLOUD; + const { onClickCopyLink } = useSharingUrl({ workspaceId, pageId: docId, - urlType: 'workspace', }); useEffect(() => { @@ -39,8 +39,7 @@ export function useRegisterCopyLinkCommands({ label: '', icon: null, run() { - track.$.cmdk.general.copyShareLink({ type: 'private' }); - + track.$.cmdk.general.copyShareLink(); isActiveView && isCloud && onClickCopyLink(); }, }) diff --git a/packages/frontend/core/src/hooks/affine/use-share-url.ts b/packages/frontend/core/src/hooks/affine/use-share-url.ts index ff9de6cfbc..ae5a19f8fb 100644 --- a/packages/frontend/core/src/hooks/affine/use-share-url.ts +++ b/packages/frontend/core/src/hooks/affine/use-share-url.ts @@ -2,112 +2,138 @@ import { notify } from '@affine/component'; import { track } from '@affine/core/mixpanel'; import { getAffineCloudBaseUrl } from '@affine/core/modules/cloud/services/fetch'; import { useI18n } from '@affine/i18n'; -import type { Disposable } from '@blocksuite/global/utils'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { BaseSelection } from '@blocksuite/block-std'; +import { type DocMode } from '@toeverything/infra'; +import { useCallback } from 'react'; import { useActiveBlocksuiteEditor } from '../use-block-suite-editor'; -type UrlType = 'share' | 'workspace'; - type UseSharingUrl = { workspaceId: string; pageId: string; - urlType: UrlType; + shareMode?: DocMode; + blockIds?: string[]; + elementIds?: string[]; + xywh?: string; // not needed currently }; +/** + * to generate a url like https://app.affine.pro/workspace/workspaceId/docId?mode=DocMode?element=seletedBlockid#seletedBlockid + */ const generateUrl = ({ workspaceId, pageId, - urlType, - blockId, -}: UseSharingUrl & { blockId?: string }) => { - // to generate a private url like https://app.affine.app/workspace/123/456 - // or https://app.affine.app/workspace/123/456#block-123 - - // to generate a public url like https://app.affine.app/share/123/456 + blockIds, + elementIds, + shareMode, + xywh, // not needed currently +}: UseSharingUrl) => { + // Base URL construction const baseUrl = getAffineCloudBaseUrl(); if (!baseUrl) return null; try { - return new URL( - `${baseUrl}/${urlType}/${workspaceId}/${pageId}${urlType === 'workspace' && blockId ? `#${blockId}` : ''}` - ).toString(); + const url = new URL(`${baseUrl}/workspace/${workspaceId}/${pageId}`); + if (shareMode) { + url.searchParams.append('mode', shareMode); + } + // TODO(@JimmFly): use query string to handle blockIds + if (blockIds && blockIds.length > 0) { + // hash is used to store blockIds + url.hash = blockIds.join(','); + } + if (elementIds && elementIds.length > 0) { + url.searchParams.append('element', elementIds.join(',')); + } + if (xywh) { + url.searchParams.append('xywh', xywh); + } + return url.toString(); } catch (e) { return null; } }; -export const useSharingUrl = ({ - workspaceId, - pageId, - urlType, -}: UseSharingUrl) => { +const getShareLinkType = ({ + shareMode, + blockIds, + elementIds, +}: { + shareMode?: DocMode; + blockIds?: string[]; + elementIds?: string[]; +}) => { + if (shareMode === 'page') { + return 'doc'; + } else if (shareMode === 'edgeless') { + return 'whiteboard'; + } else if (blockIds && blockIds.length > 0) { + return 'block'; + } else if (elementIds && elementIds.length > 0) { + return 'element'; + } else { + return 'default'; + } +}; + +const getSelectionIds = (selections?: BaseSelection[]) => { + if (!selections || selections.length === 0) { + return { blockIds: [], elementIds: [] }; + } + const blockIds: string[] = []; + const elementIds: string[] = []; + // TODO(@JimmFly): handle multiple selections and elementIds + if (selections[0].type === 'block') { + blockIds.push(selections[0].blockId); + } + return { blockIds, elementIds }; +}; + +export const useSharingUrl = ({ workspaceId, pageId }: UseSharingUrl) => { const t = useI18n(); - const [blockId, setBlockId] = useState(''); const [editor] = useActiveBlocksuiteEditor(); - const sharingUrl = useMemo( - () => - generateUrl({ + + const onClickCopyLink = useCallback( + (shareMode?: DocMode) => { + const selectManager = editor?.host?.selection; + const selections = selectManager?.value; + const { blockIds, elementIds } = getSelectionIds(selections); + + const sharingUrl = generateUrl({ workspaceId, pageId, - urlType, - blockId: blockId.length > 0 ? blockId : undefined, - }), - [workspaceId, pageId, urlType, blockId] - ); - - const onClickCopyLink = useCallback(() => { - if (sharingUrl) { - navigator.clipboard - .writeText(sharingUrl) - .then(() => { - notify.success({ - title: t['Copied link to clipboard'](), + blockIds, + elementIds, + shareMode, // if view is not provided, use the current view + }); + const type = getShareLinkType({ + shareMode, + blockIds, + elementIds, + }); + if (sharingUrl) { + navigator.clipboard + .writeText(sharingUrl) + .then(() => { + notify.success({ + title: t['Copied link to clipboard'](), + }); + }) + .catch(err => { + console.error(err); }); - }) - .catch(err => { - console.error(err); + track.$.sharePanel.$.copyShareLink({ + type, }); - track.$.sharePanel.$.copyShareLink({ - type: urlType === 'share' ? 'public' : 'private', - }); - } else { - notify.error({ - title: 'Network not available', - }); - } - }, [sharingUrl, t, urlType]); - - useEffect(() => { - let disposable: Disposable | null = null; - const selectManager = editor?.host?.selection; - if (urlType !== 'workspace' || !selectManager) { - return; - } - - // if the block is already selected, set the blockId - const currentBlockSelection = selectManager.find('block'); - if (currentBlockSelection) { - setBlockId(`#${currentBlockSelection.blockId}`); - } - - disposable = selectManager.slots.changed.on(selections => { - setBlockId(prev => { - if (selections[0] && selections[0].type === 'block') { - return `#${selections[0].blockId}`; - } else if (prev.length > 0) { - return ''; - } else { - return prev; - } - }); - }); - return () => { - disposable?.dispose(); - }; - }, [editor?.host?.selection, urlType]); + } else { + notify.error({ + title: 'Network not available', + }); + } + }, + [editor, pageId, t, workspaceId] + ); return { - sharingUrl, onClickCopyLink, }; }; diff --git a/packages/frontend/core/src/mixpanel/events.ts b/packages/frontend/core/src/mixpanel/events.ts index 795d0e7d69..b7ef65e231 100644 --- a/packages/frontend/core/src/mixpanel/events.ts +++ b/packages/frontend/core/src/mixpanel/events.ts @@ -376,7 +376,9 @@ export type EventArgs = { createDoc: { mode?: 'edgeless' | 'page' }; switchPageMode: { mode: 'edgeless' | 'page' }; createShareLink: { mode: 'edgeless' | 'page' }; - copyShareLink: { type: 'public' | 'private' }; + copyShareLink: { + type: 'default' | 'doc' | 'whiteboard' | 'block' | 'element'; + }; export: { type: string }; }; diff --git a/packages/frontend/core/src/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/pages/workspace/share/share-page.tsx index c5607a0776..2ae29632a7 100644 --- a/packages/frontend/core/src/pages/workspace/share/share-page.tsx +++ b/packages/frontend/core/src/pages/workspace/share/share-page.tsx @@ -29,6 +29,7 @@ import { } from '@toeverything/infra'; import clsx from 'clsx'; import { useCallback, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { PageNotFound } from '../../404'; import { ShareFooter } from './share-footer'; @@ -50,6 +51,18 @@ export const SharePage = ({ const error = useLiveData(shareReaderService.reader.error$); const data = useLiveData(shareReaderService.reader.data$); + const location = useLocation(); + + const [mode, setMode] = useState(null); + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const queryStringMode = searchParams.get('mode') as DocMode | null; + if (queryStringMode && ['edgeless', 'page'].includes(queryStringMode)) { + setMode(queryStringMode); + } + }, [location.search]); + useEffect(() => { shareReaderService.reader.loadShare({ workspaceId, docId }); }, [shareReaderService, docId, workspaceId]); @@ -70,7 +83,7 @@ export const SharePage = ({ docId={data.docId} workspaceBinary={data.workspaceBinary} docBinary={data.docBinary} - publishMode={data.publishMode} + publishMode={mode || data.publishMode} /> ); } else { diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index c679f18132..ed2b699481 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1279,6 +1279,19 @@ "com.affine.share-menu.ShareWithLink": "Share with link", "com.affine.share-menu.ShareWithLinkDescription": "Create a link you can easily share with anyone. The visitors will open your doc in the form od a document", "com.affine.share-menu.SharedPage": "Shared doc", + "com.affine.share-menu.option.link.label": "Anyone with the link", + "com.affine.share-menu.option.link.readonly": "Read Only", + "com.affine.share-menu.option.link.readonly.description": "Anyone can access this link.", + "com.affine.share-menu.option.link.no-access": "No Access", + "com.affine.share-menu.option.link.no-access.description": "Only workspace members can access this link.", + "com.affine.share-menu.option.permission.label": "Members in Workspace", + "com.affine.share-menu.option.permission.can-edit": "Can Edit", + "com.affine.share-menu.navigate.workspace": "Manage Workspace Members", + "com.affine.share-menu.copy": "Copy Link", + "com.affine.share-menu.copy.page": "Copy Link to Page Mode", + "com.affine.share-menu.copy.edgeless": "Copy Link to Edgeless Mode", + "com.affine.share-menu.copy.block": "Copy Link to Selected Block", + "com.affine.share-menu.copy.frame": "Copy Link to Selected Frame", "com.affine.share-menu.confirm-modify-mode.notification.fail.message": "Please try again later.", "com.affine.share-menu.confirm-modify-mode.notification.fail.title": "Failed to modify", "com.affine.share-menu.confirm-modify-mode.notification.success.message": "You have changed the public link from {{preMode}} Mode to {{currentMode}} Mode.", diff --git a/tests/affine-cloud/e2e/share-page.spec.ts b/tests/affine-cloud/e2e/share-page.spec.ts index ed56b40adb..eea6a4d5bc 100644 --- a/tests/affine-cloud/e2e/share-page.spec.ts +++ b/tests/affine-cloud/e2e/share-page.spec.ts @@ -2,6 +2,7 @@ import { skipOnboarding, test } from '@affine-test/kit/playwright'; import { createRandomUser, enableCloudWorkspaceFromShareButton, + enableShare, loginUser, } from '@affine-test/kit/utils/cloud'; import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor'; @@ -44,9 +45,11 @@ test('can enable share page', async ({ page, browser }) => { }); await page.keyboard.press('Enter', { delay: 50 }); await page.keyboard.type('TEST CONTENT', { delay: 50 }); - await page.getByTestId('cloud-share-menu-button').click(); - await page.getByTestId('share-menu-create-link-button').click(); + + // enable share page and copy page link + await enableShare(page); await page.getByTestId('share-menu-copy-link-button').click(); + await page.getByTestId('share-link-menu-copy-page').click(); // check share page is accessible { @@ -86,9 +89,11 @@ test('share page with default edgeless', async ({ page, browser }) => { await expect(page.locator('affine-edgeless-root')).toBeVisible({ timeout: 1000, }); - await page.getByTestId('cloud-share-menu-button').click(); - await page.getByTestId('share-menu-create-link-button').click(); + + // enable share page and copy page link + await enableShare(page); await page.getByTestId('share-menu-copy-link-button').click(); + await page.getByTestId('share-link-menu-copy-edgeless').click(); // check share page is accessible { @@ -126,9 +131,10 @@ test('image preview should should be shown', async ({ page, browser }) => { await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.getByTestId('cloud-share-menu-button').click(); - await page.getByTestId('share-menu-create-link-button').click(); + // enable share page and copy page link + await enableShare(page); await page.getByTestId('share-menu-copy-link-button').click(); + await page.getByTestId('share-link-menu-copy-page').click(); // check share page is accessible { diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 6ba29b042a..75e0e52a02 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -216,3 +216,9 @@ export async function enableCloudWorkspaceFromShareButton(page: Page) { await waitForEditorLoad(page); await clickNewPageButton(page); } + +export async function enableShare(page: Page) { + await page.getByTestId('cloud-share-menu-button').click(); + await page.getByTestId('share-link-menu-trigger').click(); + await page.getByTestId('share-link-menu-enable-share').click(); +} diff --git a/yarn.lock b/yarn.lock index 8929810f0b..09badf4c3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -310,6 +310,7 @@ __metadata: "@radix-ui/react-popover": "npm:^1.0.7" "@radix-ui/react-radio-group": "npm:^1.1.3" "@radix-ui/react-scroll-area": "npm:^1.0.5" + "@radix-ui/react-tabs": "npm:^1.1.0" "@radix-ui/react-toast": "npm:^1.1.5" "@radix-ui/react-toolbar": "npm:^1.0.4" "@radix-ui/react-tooltip": "npm:^1.0.7"