Skip to content

Commit fdb9b37

Browse files
authored
feat: support simple ai meeting and pdf block (#185)
* feat: support simple ai meeting and pdf block * chore: update placeholder text * chore: code review * chore: lint code * chore: update placeholder text * fix: lint error
1 parent c7c9d06 commit fdb9b37

File tree

12 files changed

+705
-0
lines changed

12 files changed

+705
-0
lines changed

src/application/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export enum BlockType {
4545
SimpleTableCellBlock = 'simple_table_cell',
4646
ColumnsBlock = 'simple_columns',
4747
ColumnBlock = 'simple_column',
48+
AIMeetingBlock = 'ai_meeting',
49+
PDFBlock = 'pdf',
4850
}
4951

5052
export enum InlineBlockType {
@@ -145,6 +147,18 @@ export interface VideoBlockData extends BlockData {
145147
video_type?: VideoType;
146148
}
147149

150+
export interface AIMeetingBlockData extends BlockData {
151+
title?: string;
152+
}
153+
154+
export interface PDFBlockData extends BlockData {
155+
name?: string;
156+
uploaded_at?: number;
157+
url?: string;
158+
url_type?: FieldURLType;
159+
retry_local_url?: string;
160+
}
161+
148162
export enum GalleryLayout {
149163
Carousel = 0,
150164
Grid = 1,

src/assets/icons/pdf.svg

Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React, { useCallback, useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useSlateStatic } from 'slate-react';
4+
5+
import { YjsEditor } from '@/application/slate-yjs';
6+
import { CustomEditor } from '@/application/slate-yjs/command';
7+
import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor';
8+
import { BlockType, FieldURLType, PDFBlockData } from '@/application/types';
9+
import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone';
10+
import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs';
11+
import { useEditorContext } from '@/components/editor/EditorContext';
12+
import { FileHandler } from '@/utils/file';
13+
14+
import EmbedLink from 'src/components/_shared/image-upload/EmbedLink';
15+
16+
export function getFileName(rawUrl: string) {
17+
try {
18+
const urlObj = new URL(rawUrl);
19+
const name = urlObj.pathname.split('/').filter(Boolean).pop();
20+
21+
return name || rawUrl;
22+
} catch {
23+
return rawUrl;
24+
}
25+
}
26+
27+
function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) {
28+
const editor = useSlateStatic() as YjsEditor;
29+
const { uploadFile } = useEditorContext();
30+
const entry = useMemo(() => {
31+
try {
32+
return findSlateEntryByBlockId(editor, blockId);
33+
} catch (e) {
34+
return null;
35+
}
36+
}, [blockId, editor]);
37+
38+
const { t } = useTranslation();
39+
40+
const [tabValue, setTabValue] = React.useState('upload');
41+
const [uploading, setUploading] = React.useState(false);
42+
43+
const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => {
44+
setTabValue(newValue);
45+
}, []);
46+
47+
const handleInsertEmbedLink = useCallback(
48+
(url: string) => {
49+
CustomEditor.setBlockData(editor, blockId, {
50+
url,
51+
name: getFileName(url),
52+
uploaded_at: Date.now(),
53+
url_type: FieldURLType.Link,
54+
} as PDFBlockData);
55+
onClose();
56+
},
57+
[blockId, editor, onClose]
58+
);
59+
60+
const uploadFileRemote = useCallback(
61+
async (file: File) => {
62+
try {
63+
if (uploadFile) {
64+
return await uploadFile(file);
65+
}
66+
} catch (e: unknown) {
67+
return;
68+
}
69+
},
70+
[uploadFile]
71+
);
72+
73+
const processFileUpload = useCallback(
74+
async (file: File): Promise<PDFBlockData> => {
75+
const url = await uploadFileRemote(file);
76+
const data: PDFBlockData = {
77+
url,
78+
name: file.name,
79+
uploaded_at: Date.now(),
80+
url_type: FieldURLType.Upload,
81+
};
82+
83+
if (!url) {
84+
const fileHandler = new FileHandler();
85+
const res = await fileHandler.handleFileUpload(file);
86+
87+
data.retry_local_url = res.id;
88+
}
89+
90+
return data;
91+
},
92+
[uploadFileRemote]
93+
);
94+
95+
const handleChangeUploadFiles = useCallback(
96+
async (files: File[]) => {
97+
if (!files.length) return;
98+
99+
setUploading(true);
100+
try {
101+
const [file, ...otherFiles] = files;
102+
const data = await processFileUpload(file);
103+
104+
CustomEditor.setBlockData(editor, blockId, data);
105+
106+
for (const file of otherFiles.reverse()) {
107+
const data = await processFileUpload(file);
108+
109+
CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data);
110+
}
111+
112+
onClose();
113+
} finally {
114+
setUploading(false);
115+
}
116+
},
117+
[blockId, editor, onClose, processFileUpload]
118+
);
119+
120+
const defaultLink = useMemo(() => {
121+
return (entry?.[0]?.data as PDFBlockData | undefined)?.url;
122+
}, [entry]);
123+
124+
const uploadLabel = t('button.upload');
125+
const embedLabel = t('document.plugins.file.networkTab');
126+
const selectedIndex = tabValue === 'upload' ? 0 : 1;
127+
128+
return (
129+
<div className={'flex flex-col gap-2 p-2'}>
130+
<ViewTabs
131+
value={tabValue}
132+
onChange={handleTabChange}
133+
className={'min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2'}
134+
>
135+
<ViewTab iconPosition='start' color='inherit' label={uploadLabel} value='upload' />
136+
<ViewTab iconPosition='start' color='inherit' label={embedLabel} value='embed' />
137+
</ViewTabs>
138+
<div className={'appflowy-scroller max-h-[400px] overflow-y-auto p-2'}>
139+
<TabPanel className={'flex h-full w-full flex-col'} index={0} value={selectedIndex}>
140+
<FileDropzone
141+
accept="application/pdf,.pdf"
142+
multiple={true}
143+
placeholder={
144+
<span>
145+
Click to upload or drag and drop PDF files
146+
<span className={'text-text-action'}> {t('document.plugins.photoGallery.browserLayout')}</span>
147+
</span>
148+
}
149+
onChange={handleChangeUploadFiles}
150+
loading={uploading}
151+
/>
152+
</TabPanel>
153+
<TabPanel className={'flex h-full w-full flex-col'} index={1} value={selectedIndex}>
154+
<EmbedLink onDone={handleInsertEmbedLink} defaultLink={defaultLink} placeholder={'Embed a PDF link'} />
155+
</TabPanel>
156+
</div>
157+
</div>
158+
);
159+
}
160+
161+
export default PDFBlockPopoverContent;

src/components/editor/components/block-popover/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { calculateOptimalOrigins, Origins, Popover } from '@/components/_shared/
88
import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext';
99
import FileBlockPopoverContent from '@/components/editor/components/block-popover/FileBlockPopoverContent';
1010
import ImageBlockPopoverContent from '@/components/editor/components/block-popover/ImageBlockPopoverContent';
11+
import PDFBlockPopoverContent from '@/components/editor/components/block-popover/PDFBlockPopoverContent';
1112
import { useEditorContext } from '@/components/editor/EditorContext';
1213

1314
import MathEquationPopoverContent from './MathEquationPopoverContent';
@@ -50,6 +51,8 @@ function BlockPopover() {
5051
switch (type) {
5152
case BlockType.FileBlock:
5253
return <FileBlockPopoverContent blockId={blockId} onClose={handleClose} />;
54+
case BlockType.PDFBlock:
55+
return <PDFBlockPopoverContent blockId={blockId} onClose={handleClose} />;
5356
case BlockType.ImageBlock:
5457
return <ImageBlockPopoverContent blockId={blockId} onClose={handleClose} />;
5558
case BlockType.EquationBlock:
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { withContainer } from '../../../../../../.storybook/decorators';
3+
import { AIMeetingBlock } from './AIMeetingBlock';
4+
import '../../../editor.scss';
5+
6+
const meta = {
7+
title: 'Editor/Blocks/AIMeetingBlock',
8+
component: AIMeetingBlock,
9+
parameters: {
10+
layout: 'centered',
11+
},
12+
tags: ['autodocs'],
13+
decorators: [withContainer({ padding: '20px', maxWidth: '800px' })],
14+
argTypes: {
15+
node: {
16+
description: 'The AI Meeting block node',
17+
control: 'object',
18+
},
19+
},
20+
} satisfies Meta<typeof AIMeetingBlock>;
21+
22+
export default meta;
23+
type Story = StoryObj<typeof meta>;
24+
25+
export const Default: Story = {
26+
args: {
27+
node: {
28+
type: 'ai_meeting',
29+
blockId: 'ai-meeting-1',
30+
children: [],
31+
data: {
32+
title: 'Weekly Team Sync',
33+
},
34+
},
35+
children: [],
36+
},
37+
};
38+
39+
export const NoTitle: Story = {
40+
args: {
41+
node: {
42+
type: 'ai_meeting',
43+
blockId: 'ai-meeting-2',
44+
children: [],
45+
data: {},
46+
},
47+
children: [],
48+
},
49+
};
50+
51+
export const LongTitle: Story = {
52+
args: {
53+
node: {
54+
type: 'ai_meeting',
55+
blockId: 'ai-meeting-3',
56+
children: [],
57+
data: {
58+
title: 'Quarterly Business Review Meeting with All Stakeholders and Department Heads - Q4 2025',
59+
},
60+
},
61+
children: [],
62+
},
63+
};
64+
65+
export const ShortTitle: Story = {
66+
args: {
67+
node: {
68+
type: 'ai_meeting',
69+
blockId: 'ai-meeting-4',
70+
children: [],
71+
data: {
72+
title: 'Standup',
73+
},
74+
},
75+
children: [],
76+
},
77+
};
78+
79+
export const EmptyTitle: Story = {
80+
args: {
81+
node: {
82+
type: 'ai_meeting',
83+
blockId: 'ai-meeting-5',
84+
children: [],
85+
data: {
86+
title: ' ',
87+
},
88+
},
89+
children: [],
90+
},
91+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { AIMeetingNode, EditorElementProps } from '@/components/editor/editor.type';
2+
import { forwardRef, memo } from 'react';
3+
4+
export const AIMeetingBlock = memo(
5+
forwardRef<HTMLDivElement, EditorElementProps<AIMeetingNode>>(
6+
({ node, children: _children, ...attributes }, ref) => {
7+
const { data } = node;
8+
9+
const title = data?.title?.trim() || 'AI Meeting';
10+
11+
return (
12+
<div
13+
{...attributes}
14+
ref={ref}
15+
className={`${attributes.className ?? ''} ai-meeting-block my-2 overflow-hidden rounded-2xl bg-fill-list-active`}
16+
contentEditable={false}
17+
>
18+
<div className="px-4 py-4">
19+
<h2 className="text-3xl font-semibold text-text-primary">
20+
{title}
21+
</h2>
22+
</div>
23+
24+
<div className="mx-0.5 mb-0.5 rounded-2xl bg-bg-body">
25+
<div className="flex flex-col items-center justify-center px-8 py-10">
26+
<p className="text-base text-text-secondary">
27+
This content isn&apos;t supported on the web version yet.
28+
</p>
29+
<p className="text-base text-text-secondary">
30+
Please switch to the desktop or mobile app to view this content.
31+
</p>
32+
</div>
33+
</div>
34+
</div>
35+
);
36+
}
37+
)
38+
);
39+
40+
AIMeetingBlock.displayName = 'AIMeetingBlock';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './AIMeetingBlock';

0 commit comments

Comments
 (0)