feat: single page sharing support (#1805)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
JimmFly 2023-04-12 06:58:11 +08:00 committed by GitHub
parent f3af128baf
commit 2e823c2fee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 652 additions and 31 deletions

View File

@ -106,6 +106,7 @@ export const Header = forwardRef<
}),
[rightItems]
)}
{/*<ShareMenu />*/}
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>

View File

@ -109,7 +109,11 @@ export const BlockSuiteEditorHeader = forwardRef<
? ['themeModeSwitch']
: isTrash
? ['trashButtonGroup']
: ['syncUser', 'themeModeSwitch', 'editorOptionMenu']
: [
'syncUser',
/* 'shareMenu', */ 'themeModeSwitch',
'editorOptionMenu',
]
}
{...props}
>

View File

@ -0,0 +1,40 @@
import { ContentParser } from '@blocksuite/blocks/content-parser';
import type { FC } from 'react';
import { useRef } from 'react';
import { Button } from '../..';
import type { ShareMenuProps } from './index';
import { actionsStyle, descriptionStyle, menuItemStyle } from './index.css';
export const Export: FC<ShareMenuProps> = props => {
const contentParserRef = useRef<ContentParser>();
return (
<div className={menuItemStyle}>
<div className={descriptionStyle}>
Download a static copy of your page to share with others.
</div>
<div className={actionsStyle}>
<Button
onClick={() => {
if (!contentParserRef.current) {
contentParserRef.current = new ContentParser(props.currentPage);
}
return contentParserRef.current.onExportHtml();
}}
>
Export to HTML
</Button>
<Button
onClick={() => {
if (!contentParserRef.current) {
contentParserRef.current = new ContentParser(props.currentPage);
}
return contentParserRef.current.onExportMarkdown();
}}
>
Export to Markdown
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,70 @@
import { getEnvironment } from '@affine/env';
import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public';
import type { FC } from 'react';
import { useCallback, useMemo } from 'react';
import { Button } from '../..';
import type { ShareMenuProps } from './index';
import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css';
export const LocalSharePage: FC<ShareMenuProps> = props => {
return (
<div className={menuItemStyle}>
<div className={descriptionStyle}>
Sharing page publicly requires AFFiNE Cloud service.
</div>
<Button
data-testid="share-menu-enable-affine-cloud-button"
className={buttonStyle}
type="light"
shape="round"
onClick={() => {
props.onEnableAffineCloud(props.workspace as LocalWorkspace);
}}
>
Enable AFFiNE Cloud
</Button>
</div>
);
};
export const AffineSharePage: FC<ShareMenuProps> = props => {
const [isPublic, setIsPublic] = useBlockSuiteWorkspacePageIsPublic(
props.currentPage
);
const sharingUrl = useMemo(() => {
const env = getEnvironment();
if (env.isBrowser) {
return `${env.origin}/public-workspace/${props.workspace.id}/${props.currentPage.id}`;
} else {
return '';
}
}, [props.workspace.id, props.currentPage.id]);
const onClickCreateLink = useCallback(() => {
setIsPublic(true);
}, [isPublic]);
const onClickCopyLink = useCallback(() => {
navigator.clipboard.writeText(sharingUrl);
}, []);
return (
<div className={menuItemStyle}>
<div className={descriptionStyle}>
Create a link you can easily share with anyone.
</div>
<span>{isPublic ? sharingUrl : 'not public'}</span>
{!isPublic && <Button onClick={onClickCreateLink}>Create</Button>}
{isPublic && <Button onClick={onClickCopyLink}>Copy Link</Button>}
</div>
);
};
export const SharePage: FC<ShareMenuProps> = props => {
if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <LocalSharePage {...props} />;
} else if (props.workspace.flavour === WorkspaceFlavour.AFFINE) {
return <AffineSharePage {...props} />;
}
throw new Error('Unreachable');
};

View File

@ -0,0 +1,64 @@
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { FC } from 'react';
import { Button } from '../..';
import type { ShareMenuProps } from '.';
import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css';
const ShareLocalWorkspace: FC<ShareMenuProps<LocalWorkspace>> = props => {
return (
<div className={menuItemStyle}>
<div className={descriptionStyle}>
Sharing page publicly requires AFFiNE Cloud service.
</div>
<Button
data-testid="share-menu-enable-affine-cloud-button"
className={buttonStyle}
type="light"
shape="circle"
onClick={() => {
props.onEnableAffineCloud(props.workspace as LocalWorkspace);
}}
>
Enable AFFiNE Cloud
</Button>
</div>
);
};
const ShareAffineWorkspace: FC<ShareMenuProps<AffineWorkspace>> = props => {
const isPublicWorkspace = props.workspace.public;
return (
<div className={menuItemStyle}>
<div className={descriptionStyle}>
{isPublicWorkspace
? `Current workspace has been published to the web as a public workspace.`
: `Invite others to join the Workspace or publish it to web`}
</div>
<Button
data-testid="share-menu-publish-to-web-button"
onClick={() => {
props.onOpenWorkspaceSettings(props.workspace);
}}
type="light"
shape="circle"
>
Open Workspace Settings
</Button>
</div>
);
};
export const ShareWorkspace: FC<ShareMenuProps> = props => {
if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return (
<ShareLocalWorkspace {...(props as ShareMenuProps<LocalWorkspace>)} />
);
} else if (props.workspace.flavour === WorkspaceFlavour.AFFINE) {
return (
<ShareAffineWorkspace {...(props as ShareMenuProps<AffineWorkspace>)} />
);
}
throw new Error('Unreachable');
};

View File

@ -0,0 +1,34 @@
import { style } from '@vanilla-extract/css';
export const tabStyle = style({
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
position: 'relative',
marginTop: '4px',
marginLeft: '10px',
marginRight: '10px',
});
export const menuItemStyle = style({
marginLeft: '20px',
marginRight: '20px',
marginTop: '30px',
});
export const descriptionStyle = style({
fontSize: '1rem',
});
export const buttonStyle = style({
marginTop: '18px',
// todo: new color scheme should be used
});
export const actionsStyle = style({
display: 'flex',
gap: '9px',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'start',
});

View File

@ -0,0 +1,100 @@
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { ExportIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { Menu } from '../..';
import { Export } from './Export';
import { tabStyle } from './index.css';
import { SharePage } from './SharePage';
import { ShareWorkspace } from './ShareWorkspace';
import { StyledIndicator, StyledShareButton, TabItem } from './styles';
type SharePanel = 'SharePage' | 'Export' | 'ShareWorkspace';
const MenuItems: Record<SharePanel, FC<ShareMenuProps>> = {
SharePage: SharePage,
Export: Export,
ShareWorkspace: ShareWorkspace,
};
export type ShareMenuProps<
Workspace extends AffineWorkspace | LocalWorkspace =
| AffineWorkspace
| LocalWorkspace
> = {
workspace: Workspace;
currentPage: Page;
onEnableAffineCloud: (workspace: LocalWorkspace) => void;
onOpenWorkspaceSettings: (workspace: Workspace) => void;
togglePagePublic: (page: Page, publish: boolean) => Promise<void>;
toggleWorkspacePublish: (
workspace: Workspace,
publish: boolean
) => Promise<void>;
};
export const ShareMenu: FC<ShareMenuProps> = props => {
const [activeItem, setActiveItem] = useState<SharePanel>('SharePage');
const [open, setOpen] = useState(false);
const handleMenuChange = useCallback((selectedItem: SharePanel) => {
setActiveItem(selectedItem);
}, []);
const ActiveComponent = MenuItems[activeItem];
interface ShareMenuProps {
activeItem: SharePanel;
onChangeTab: (selectedItem: SharePanel) => void;
}
const ShareMenu: FC<ShareMenuProps> = ({ activeItem, onChangeTab }) => {
const handleButtonClick = (itemName: SharePanel) => {
onChangeTab(itemName);
setActiveItem(itemName);
};
return (
<div className={tabStyle}>
{Object.keys(MenuItems).map(item => (
<TabItem
isActive={activeItem === item}
key={item}
onClick={() => handleButtonClick(item as SharePanel)}
>
{item}
</TabItem>
))}
</div>
);
};
const activeIndex = Object.keys(MenuItems).indexOf(activeItem);
const Share = (
<>
<ShareMenu activeItem={activeItem} onChangeTab={handleMenuChange} />
<StyledIndicator activeIndex={activeIndex} />
<ActiveComponent {...props} />
</>
);
return (
<Menu
content={Share}
visible={open}
width={439}
placement="bottom-end"
trigger={['click']}
disablePortal={true}
onClickAway={() => {
setOpen(false);
}}
>
<StyledShareButton
data-testid="share-menu-button"
onClick={() => {
setOpen(!open);
}}
>
<ExportIcon />
<div>Share</div>
</StyledShareButton>
</Menu>
);
};

View File

@ -0,0 +1,64 @@
import { displayFlex, styled, TextButton } from '../..';
export const StyledShareButton = styled(TextButton)(({ theme }) => {
return {
padding: '4px 8px',
marginLeft: '4px',
marginRight: '16px',
border: `1px solid ${theme.colors.primaryColor}`,
color: theme.colors.primaryColor,
borderRadius: '8px',
span: {
...displayFlex('center', 'center'),
},
};
});
export const StyledTabsWrapper = styled('div')(() => {
return {
...displayFlex('space-around', 'center'),
position: 'relative',
};
});
export const TabItem = styled('li')<{ isActive?: boolean }>(
({ theme, isActive }) => {
{
return {
...displayFlex('center', 'center'),
width: 'calc(100% / 3)',
height: '34px',
color: theme.colors.textColor,
opacity: isActive ? 1 : 0.2,
fontWeight: '500',
fontSize: theme.font.h6,
lineHeight: theme.font.lineHeight,
cursor: 'pointer',
transition: 'all 0.15s ease',
':after': {
content: '""',
position: 'absolute',
bottom: '-2px',
left: '-2px',
width: 'calc(100% + 4px)',
height: '2px',
background: theme.colors.textColor,
opacity: 0.2,
},
};
}
}
);
export const StyledIndicator = styled('div')<{ activeIndex: number }>(
({ theme, activeIndex }) => {
return {
height: '2px',
margin: '0 10px',
background: theme.colors.textColor,
position: 'absolute',
left: `calc(${activeIndex * 100}% / 3)`,
width: `calc(100% / 3)`,
transition: 'left .3s, width .3s',
};
}
);

View File

@ -12,6 +12,7 @@ export * from './ui/modal';
export * from './ui/mui';
export * from './ui/popper';
export * from './ui/shared/Container';
export * from './ui/switch';
export * from './ui/table';
export * from './ui/toast';
export * from './ui/tooltip';

View File

@ -0,0 +1,102 @@
import { PermissionType, WorkspaceType } from '@affine/workspace/affine/api';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import type { Page } from '@blocksuite/store';
import { expect } from '@storybook/jest';
import type { StoryFn } from '@storybook/react';
import { ShareMenu } from '../components/share-menu';
import toast from '../ui/toast/toast';
export default {
title: 'AFFiNE/ShareMenu',
component: ShareMenu,
};
function initPage(page: Page): void {
// Add page block and surface block at root level
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text('Hello, world!'),
});
page.addBlock('affine:surface', {}, null);
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
page.addBlock(
'affine:paragraph',
{
text: new page.Text('This is a paragraph.'),
},
frameId
);
page.resetHistory();
}
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace('test-workspace');
initPage(blockSuiteWorkspace.createPage('page0'));
initPage(blockSuiteWorkspace.createPage('page1'));
initPage(blockSuiteWorkspace.createPage('page2'));
const localWorkspace: LocalWorkspace = {
id: 'test-workspace',
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace,
providers: [],
};
const affineWorkspace: AffineWorkspace = {
id: 'test-workspace',
flavour: WorkspaceFlavour.AFFINE,
blockSuiteWorkspace,
providers: [],
public: false,
type: WorkspaceType.Normal,
permission: PermissionType.Owner,
};
async function unimplemented() {
toast('work in progress');
}
export const Basic: StoryFn = () => {
return (
<ShareMenu
currentPage={blockSuiteWorkspace.getPage('page0') as Page}
workspace={localWorkspace}
onEnableAffineCloud={unimplemented}
onOpenWorkspaceSettings={unimplemented}
togglePagePublic={unimplemented}
toggleWorkspacePublish={unimplemented}
/>
);
};
Basic.play = async ({ canvasElement }) => {
{
const button = canvasElement.querySelector(
'[data-testid="share-menu-button"]'
) as HTMLButtonElement;
expect(button).not.toBeNull();
button.click();
}
await new Promise(resolve => setTimeout(resolve, 100));
{
const button = canvasElement.querySelector(
'[data-testid="share-menu-enable-affine-cloud-button"]'
);
expect(button).not.toBeNull();
}
};
export const AffineBasic: StoryFn = () => {
return (
<ShareMenu
currentPage={blockSuiteWorkspace.getPage('page0') as Page}
workspace={affineWorkspace}
onEnableAffineCloud={unimplemented}
onOpenWorkspaceSettings={unimplemented}
togglePagePublic={unimplemented}
toggleWorkspacePublish={unimplemented}
/>
);
};

View File

@ -0,0 +1,16 @@
/* deepscan-disable USELESS_ARROW_FUNC_BIND */
import type { StoryFn } from '@storybook/react';
import { Switch } from '..';
export default {
title: 'AFFiNE/Switch',
component: Switch,
};
export const Basic: StoryFn = () => {
return <Switch />;
};
Basic.args = {
logoSrc: '/imgs/affine-text-logo.png',
};

View File

@ -117,7 +117,6 @@ export const StyledTextButton = styled('button', {
// type = 'default',
}) => {
const { fontSize, borderRadius, padding, height } = getSize(size);
console.log('size', size, height);
return {
height,

View File

@ -0,0 +1,80 @@
// components/Switch.tsx
import { styled } from '@affine/component';
import { useState } from 'react';
const StyledLabel = styled('label')(({ theme }) => {
return {
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
};
});
const StyledInput = styled('input')(({ theme }) => {
return {
opacity: 0,
position: 'absolute',
'&:checked': {
'& + span': {
background: '#6880FF',
'&:before': {
transform: 'translate(28px, -50%)',
},
},
},
};
});
const StyledSwitch = styled('span')(() => {
return {
position: 'relative',
width: '60px',
height: '28px',
background: '#b3b3b3',
borderRadius: '32px',
padding: '4px',
transition: '300ms all',
'&:before': {
transition: '300ms all',
content: '""',
position: 'absolute',
width: '28px',
height: '28px',
borderRadius: '35px',
top: '50%',
left: '4px',
background: 'white',
transform: 'translate(-4px, -50%)',
},
};
});
type SwitchProps = {
checked?: boolean;
onChange?: (checked: boolean) => void;
children?: React.ReactNode;
};
export const Switch = (props: SwitchProps) => {
const { checked, onChange, children } = props;
const [isChecked, setIsChecked] = useState(checked);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = event.target.checked;
setIsChecked(newChecked);
onChange?.(newChecked);
};
return (
<StyledLabel>
{children}
<StyledInput
type="checkbox"
checked={isChecked}
onChange={handleChange}
/>
<StyledSwitch />
</StyledLabel>
);
};

View File

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

View File

@ -4,7 +4,41 @@ import { z } from 'zod';
import { getUaHelper } from './ua-helper';
export const publicRuntimeConfigSchema = z.object({
PROJECT_NAME: z.string(),
BUILD_DATE: z.string(),
gitVersion: z.string(),
hash: z.string(),
serverAPI: z.string(),
editorVersion: z.string(),
enableIndexedDBProvider: z.boolean(),
enableBroadCastChannelProvider: z.boolean(),
prefetchWorkspace: z.boolean(),
enableDebugPage: z.boolean(),
// expose internal api to globalThis, **development only**
exposeInternal: z.boolean(),
enableSubpage: z.boolean(),
enableChangeLog: z.boolean(),
});
export type PublicRuntimeConfig = z.infer<typeof publicRuntimeConfigSchema>;
const { publicRuntimeConfig: config } =
getConfig() ??
({
publicRuntimeConfig: {},
} as {
publicRuntimeConfig: PublicRuntimeConfig;
});
publicRuntimeConfigSchema.parse(config);
type BrowserBase = {
/**
* @example https://app.affine.pro
* @example http://localhost:3000
*/
origin: string;
isDesktop: boolean;
isBrowser: true;
isServer: false;
@ -66,7 +100,9 @@ export function getEnvironment() {
} satisfies Server;
} else {
const uaHelper = getUaHelper();
environment = {
origin: window.location.origin,
isDesktop: window.appInfo?.electron,
isBrowser: true,
isServer: false,
@ -97,35 +133,6 @@ export function getEnvironment() {
return environment;
}
export const publicRuntimeConfigSchema = z.object({
PROJECT_NAME: z.string(),
BUILD_DATE: z.string(),
gitVersion: z.string(),
hash: z.string(),
serverAPI: z.string(),
editorVersion: z.string(),
enableIndexedDBProvider: z.boolean(),
enableBroadCastChannelProvider: z.boolean(),
prefetchWorkspace: z.boolean(),
enableDebugPage: z.boolean(),
// expose internal api to globalThis, **development only**
exposeInternal: z.boolean(),
enableSubpage: z.boolean(),
enableChangeLog: z.boolean(),
});
export type PublicRuntimeConfig = z.infer<typeof publicRuntimeConfigSchema>;
const { publicRuntimeConfig: config } =
getConfig() ??
({
publicRuntimeConfig: {},
} as {
publicRuntimeConfig: PublicRuntimeConfig;
});
publicRuntimeConfigSchema.parse(config);
function printBuildInfo() {
console.group('Build info');
console.log('Project:', config.PROJECT_NAME);

View File

@ -8,6 +8,7 @@ import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { renderHook } from '@testing-library/react';
import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public';
import { useBlockSuiteWorkspacePageTitle } from '@toeverything/hooks/use-blocksuite-workspace-page-title';
import { describe, expect, test } from 'vitest';
import { beforeEach } from 'vitest';
@ -60,3 +61,16 @@ describe('useBlockSuiteWorkspacePageTitle', () => {
expect(pageTitleHook.result.current).toBe('1');
});
});
describe('useBlockSuiteWorkspacePageIsPublic', () => {
test('basic', async () => {
const page = blockSuiteWorkspace.getPage('page0') as Page;
expect(page).not.toBeNull();
const hook = renderHook(() => useBlockSuiteWorkspacePageIsPublic(page));
expect(hook.result.current[0]).toBe(false);
hook.result.current[1](true);
expect(page.meta.isPublic).toBe(true);
hook.rerender();
expect(hook.result.current[0]).toBe(true);
});
});

View File

@ -0,0 +1,24 @@
import type { Page } from '@blocksuite/store';
import { useCallback, useEffect, useState } from 'react';
declare module '@blocksuite/store' {
interface PageMeta {
isPublic?: boolean;
}
}
export function useBlockSuiteWorkspacePageIsPublic(page: Page) {
const [isPublic, set] = useState<boolean>(() => page.meta.isPublic ?? false);
useEffect(() => {
page.workspace.meta.pageMetasUpdated.on(() => {
set(page.meta.isPublic ?? false);
});
}, []);
const setIsPublic = useCallback((isPublic: boolean) => {
set(isPublic);
page.workspace.setPageMeta(page.id, {
isPublic,
});
}, []);
return [isPublic, setIsPublic] as const;
}