feat(core): new share menu (#7838)

close AF-1224 AF-1216

![image](https://github.com/user-attachments/assets/204c408a-3dab-4068-86f6-e20abcfa863c)
This commit is contained in:
JimmFly
2024-08-19 05:51:05 +00:00
parent cfac3ebf1f
commit 4916eea24f
18 changed files with 698 additions and 408 deletions

View File

@@ -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",

View File

@@ -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';

View File

@@ -0,0 +1 @@
export * from './tabs';

View File

@@ -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',
},
},
});

View File

@@ -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<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<TabsGroup.Root
{...props}
ref={ref}
className={clsx(className, styles.tabsRoot)}
>
{children}
</TabsGroup.Root>
);
});
TabsRoot.displayName = 'TabsRoot';
export const TabsList = forwardRef<
HTMLDivElement,
TabsGroup.TabsListProps & RefAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<TabsGroup.List
{...props}
ref={ref}
className={clsx(className, styles.tabsList)}
>
{children}
</TabsGroup.List>
);
});
TabsList.displayName = 'TabsList';
export const TabsTrigger = forwardRef<
HTMLButtonElement,
TabsGroup.TabsTriggerProps & RefAttributes<HTMLButtonElement>
>(({ children, className, ...props }, ref) => {
return (
<TabsGroup.Trigger
{...props}
ref={ref}
className={clsx(className, styles.tabsTrigger)}
>
{children}
</TabsGroup.Trigger>
);
});
TabsTrigger.displayName = 'TabsTrigger';
export const TabsContent = forwardRef<
HTMLDivElement,
TabsGroup.TabsContentProps & RefAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
return (
<TabsGroup.Content
{...props}
ref={ref}
className={clsx(className, styles.tabsContent)}
>
{children}
</TabsGroup.Content>
);
});
TabsContent.displayName = 'TabsContent';
export const Tabs = {
Root: TabsRoot,
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
};

View File

@@ -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'),
});

View File

@@ -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 (
<>
<div className={styles.titleContainerStyle}>
{t['com.affine.share-menu.ShareViaExport']()}
</div>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.ShareViaExportDescription']()}
</div>
<div>
<ExportMenuItems
exportHandler={exportHandler}
className={styles.menuItemStyle}
className={styles.exportItemStyle}
pageMode={currentMode}
/>
</div>
{workspace.flavour !== WorkspaceFlavour.LOCAL ? (
<div className={styles.columnContainerStyle}>
<Divider size="thinner" />
<div className={styles.titleContainerStyle}>
{t['com.affine.share-menu.share-privately']()}
</div>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.share-privately.description']()}
</div>
<div>
<MenuItem
className={styles.shareLinkStyle}
onSelect={onClickCopyLink}
block
disabled={!sharingUrl}
preFix={
<MenuIcon>
<CopyIcon fontSize={16} />
</MenuIcon>
}
endFix={
<div className={styles.shortcutStyle}>
{isMac ? '⌘ + ⌥ + C' : 'Ctrl + Shift + C'}
</div>
}
>
{t['com.affine.share-menu.copy-private-link']()}
</MenuItem>
</div>
</div>
) : null}
</>
);
};

View File

@@ -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 (
<div className={styles.containerStyle}>
<div className={styles.headerStyle}>
<div className={styles.shareIconStyle}>
<WebIcon />
</div>
{t['com.affine.share-menu.SharePage']()}
</div>
<Tabs.Root defaultValue="share">
<Tabs.List>
<Tabs.Trigger value="share">
{t['com.affine.share-menu.shareButton']()}
</Tabs.Trigger>
<Tabs.Trigger value="export">{t['Export']()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="share">
<SharePage {...props} />
<div className={styles.columnContainerStyle}>
<Divider size="thinner" />
</div>
</Tabs.Content>
<Tabs.Content value="export">
<ShareExport {...props} />
</Tabs.Content>
</Tabs.Root>
</div>
);
};
@@ -56,11 +59,20 @@ const DefaultShareButton = forwardRef(function DefaultShareButton(
}, [shareInfoService]);
return (
<Button ref={ref} className={styles.shareButton} variant="primary">
{shared
? t['com.affine.share-menu.sharedButton']()
: t['com.affine.share-menu.shareButton']()}
<Tooltip
content={
shared
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']()
}
>
<Button ref={ref} className={styles.button}>
<div className={styles.buttonContainer}>
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
{t['com.affine.share-menu.shareButton']()}
</div>
</Button>
</Tooltip>
);
});
@@ -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,

View File

@@ -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: <SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />,
});
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 {
const onClickDisable = useCallback(() => {
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: (
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
),
});
setShowDisable(true);
}
} 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'
](),
}, [isSharedPage]);
const isMac = environment.isBrowser && environment.isMacOs;
const { onClickCopyLink } = useSharingUrl({
workspaceId,
pageId: editor.doc.id,
});
console.error(err);
}
},
[isSharedPage, shareInfoService.shareInfo, t]
);
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 (
<>
<div className={styles.content}>
<div className={styles.titleContainerStyle}>
{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']()}
</div>
<div className={styles.columnContainerStyle}>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.publish-to-web.description']()}
</div>
</div>
<div className={styles.rowContainerStyle}>
<Input
inputStyle={{
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
lineHeight: '20px',
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.link.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
value={(isSharedPage && sharingUrl) || `${baseUrl}/...`}
readOnly
/>
{isSharedPage ? (
<Button
onClick={onClickCopyLink}
data-testid="share-menu-copy-link-button"
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
disabled={!sharingUrl}
items={
<>
<MenuItem
preFix={
<LockIcon className={styles.publicMenuItemPrefixStyle} />
}
onSelect={onClickDisable}
className={styles.menuItemStyle}
>
{t.Copy()}
</Button>
) : (
<Button
onClick={onClickCreateLink}
variant="primary"
data-testid="share-menu-create-link-button"
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
>
{t.Create()}
</Button>
<div className={styles.publicItemRowStyle}>
<div>
{t['com.affine.share-menu.option.link.no-access']()}
</div>
{!isSharedPage && (
<DoneIcon className={styles.DoneIconStyle} />
)}
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
{t['com.affine.share-menu.ShareMode']()}
</div>
</MenuItem>
<MenuItem
preFix={
<ViewIcon className={styles.publicMenuItemPrefixStyle} />
}
className={styles.menuItemStyle}
onSelect={onClickAnyoneReadOnlyShare}
data-testid="share-link-menu-enable-share"
>
<div className={styles.publicItemRowStyle}>
<div>
<RadioGroup
className={styles.radioButtonGroup}
value={mode}
onChange={onShareModeChange}
items={modeOptions}
/>
{t['com.affine.share-menu.option.link.readonly']()}
</div>
{isSharedPage && (
<DoneIcon className={styles.DoneIconStyle} />
)}
</div>
</MenuItem>
</>
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
data-testid="share-link-menu-trigger"
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</MenuTrigger>
</Menu>
</div>
{isSharedPage ? (
<>
{runtimeConfig.enableEnhanceShareMode && (
<>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>Link expires</div>
<div>
<Menu items={<MenuItem>Never</MenuItem>}>
<MenuTrigger>Never</MenuTrigger>
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.permission.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
<MenuItem>
{t['com.affine.share-menu.option.permission.can-edit']()}
</MenuItem>
}
>
<MenuTrigger className={styles.menuTriggerStyle} disabled>
{t['com.affine.share-menu.option.permission.can-edit']()}
</MenuTrigger>
</Menu>
</div>
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
{'Show "Created with AFFiNE"'}
</div>
<div>
<Switch />
</div>
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
Search engine indexing
</div>
<div>
<Switch />
</div>
</div>
</>
)}
<MenuItem
endFix={<ArrowRightSmallIcon />}
block
type="danger"
className={styles.menuItemStyle}
onSelect={e => {
e.preventDefault();
setShowDisable(true);
}}
{isOwner && (
<div
className={styles.openWorkspaceSettingsStyle}
onClick={onOpenWorkspaceSettings}
>
<div className={styles.disableSharePage}>
{t['Disable Public Link']()}
<CollaborationIcon fontSize={16} />
{t['com.affine.share-menu.navigate.workspace']()}
</div>
)}
<div className={styles.copyLinkContainerStyle}>
<Button
className={styles.copyLinkButtonStyle}
onClick={onCopyBlockLink}
variant="primary"
withoutHover
>
<span className={styles.copyLinkLabelStyle}>
{t['com.affine.share-menu.copy']()}
</span>
<span className={styles.copyLinkShortcutStyle}>
{isMac ? '⌘ + ⌥ + C' : 'Ctrl + Shift + C'}
</span>
{t['com.affine.share-menu.copy']()}
</Button>
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
preFix={
<PageIcon className={styles.publicMenuItemPrefixStyle} />
}
className={styles.menuItemStyle}
onSelect={onCopyPageLink}
data-testid="share-link-menu-copy-page"
>
{t['com.affine.share-menu.copy.page']()}
</MenuItem>
<MenuItem
preFix={
<EdgelessIcon className={styles.publicMenuItemPrefixStyle} />
}
className={styles.menuItemStyle}
onSelect={onCopyEdgelessLink}
data-testid="share-link-menu-copy-edgeless"
>
{t['com.affine.share-menu.copy.edgeless']()}
</MenuItem>
<MenuItem
preFix={
<LinkIcon className={styles.publicMenuItemPrefixStyle} />
}
className={styles.menuItemStyle}
onSelect={onCopyBlockLink}
>
{t['com.affine.share-menu.copy.block']()}
</MenuItem>
{currentDocMode === 'edgeless' && (
<MenuItem
preFix={
<LinkIcon className={styles.publicMenuItemPrefixStyle} />
}
className={styles.menuItemStyle}
onSelect={onCopyBlockLink}
>
{t['com.affine.share-menu.copy.frame']()}
</MenuItem>
)}
</>
}
>
<MenuTrigger
className={styles.copyLinkTriggerStyle}
data-testid="share-menu-copy-link-button"
/>
</Menu>
</div>
<PublicLinkDisableModal
open={showDisable}
onConfirm={onDisablePublic}
onOpenChange={setShowDisable}
/>
</>
) : null}
</>
</div>
);
};
@@ -339,7 +372,7 @@ export const SharePage = (props: ShareMenuProps) => {
// TODO(@eyhn): refactor this part
<ErrorBoundary fallback={null}>
<Suspense>
<AffineSharePage {...props} />
<AFFiNESharePage {...props} />
</Suspense>
</ErrorBoundary>
);

View File

@@ -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();

View File

@@ -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();
},
})

View File

@@ -2,61 +2,115 @@ 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 t = useI18n();
const [blockId, setBlockId] = useState<string>('');
const [editor] = useActiveBlocksuiteEditor();
const sharingUrl = useMemo(
() =>
generateUrl({
workspaceId,
pageId,
urlType,
blockId: blockId.length > 0 ? blockId : undefined,
}),
[workspaceId, pageId, urlType, blockId]
);
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 onClickCopyLink = useCallback(() => {
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 [editor] = useActiveBlocksuiteEditor();
const onClickCopyLink = useCallback(
(shareMode?: DocMode) => {
const selectManager = editor?.host?.selection;
const selections = selectManager?.value;
const { blockIds, elementIds } = getSelectionIds(selections);
const sharingUrl = generateUrl({
workspaceId,
pageId,
blockIds,
elementIds,
shareMode, // if view is not provided, use the current view
});
const type = getShareLinkType({
shareMode,
blockIds,
elementIds,
});
if (sharingUrl) {
navigator.clipboard
.writeText(sharingUrl)
@@ -69,45 +123,17 @@ export const useSharingUrl = ({
console.error(err);
});
track.$.sharePanel.$.copyShareLink({
type: urlType === 'share' ? 'public' : 'private',
type,
});
} 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]);
},
[editor, pageId, t, workspaceId]
);
return {
sharingUrl,
onClickCopyLink,
};
};

View File

@@ -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 };
};

View File

@@ -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<DocMode | null>(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 {

View File

@@ -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.",

View File

@@ -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
{

View File

@@ -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();
}

View File

@@ -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"