Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export enum BlockType {
SimpleTableCellBlock = 'simple_table_cell',
ColumnsBlock = 'simple_columns',
ColumnBlock = 'simple_column',
AIMeetingBlock = 'ai_meeting',
PDFBlock = 'pdf',
}

export enum InlineBlockType {
Expand Down Expand Up @@ -145,6 +147,18 @@ export interface VideoBlockData extends BlockData {
video_type?: VideoType;
}

export interface AIMeetingBlockData extends BlockData {
title?: string;
}

export interface PDFBlockData extends BlockData {
name?: string;
uploaded_at?: number;
url?: string;
url_type?: FieldURLType;
retry_local_url?: string;
}

export enum GalleryLayout {
Carousel = 0,
Grid = 1,
Expand Down
4 changes: 4 additions & 0 deletions src/assets/icons/pdf.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSlateStatic } from 'slate-react';

import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor';
import { BlockType, FieldURLType, PDFBlockData } from '@/application/types';
import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone';
import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs';
import { useEditorContext } from '@/components/editor/EditorContext';
import { FileHandler } from '@/utils/file';

import EmbedLink from 'src/components/_shared/image-upload/EmbedLink';

export function getFileName(rawUrl: string) {
try {
const urlObj = new URL(rawUrl);
const name = urlObj.pathname.split('/').filter(Boolean).pop();

return name || rawUrl;
} catch {
return rawUrl;
}
}

function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) {
const editor = useSlateStatic() as YjsEditor;
const { uploadFile } = useEditorContext();
const entry = useMemo(() => {
try {
return findSlateEntryByBlockId(editor, blockId);
} catch (e) {
return null;
}
}, [blockId, editor]);

const { t } = useTranslation();

const [tabValue, setTabValue] = React.useState('upload');
const [uploading, setUploading] = React.useState(false);

const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
}, []);

const handleInsertEmbedLink = useCallback(
(url: string) => {
CustomEditor.setBlockData(editor, blockId, {
url,
name: getFileName(url),
uploaded_at: Date.now(),
url_type: FieldURLType.Link,
} as PDFBlockData);
onClose();
},
[blockId, editor, onClose]
);

const uploadFileRemote = useCallback(
async (file: File) => {
try {
if (uploadFile) {
return await uploadFile(file);
}
} catch (e: unknown) {
return;
}
},
[uploadFile]
);

const processFileUpload = useCallback(
async (file: File): Promise<PDFBlockData> => {
const url = await uploadFileRemote(file);
const data: PDFBlockData = {
url,
name: file.name,
uploaded_at: Date.now(),
url_type: FieldURLType.Upload,
};

if (!url) {
const fileHandler = new FileHandler();
const res = await fileHandler.handleFileUpload(file);

data.retry_local_url = res.id;
}

return data;
},
[uploadFileRemote]
);

const handleChangeUploadFiles = useCallback(
async (files: File[]) => {
if (!files.length) return;

setUploading(true);
try {
const [file, ...otherFiles] = files;
const data = await processFileUpload(file);

CustomEditor.setBlockData(editor, blockId, data);

for (const file of otherFiles.reverse()) {
const data = await processFileUpload(file);

CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data);
}

onClose();
} finally {
setUploading(false);
}
},
[blockId, editor, onClose, processFileUpload]
);

const defaultLink = useMemo(() => {
return (entry?.[0]?.data as PDFBlockData | undefined)?.url;
}, [entry]);

const uploadLabel = t('button.upload');
const embedLabel = t('document.plugins.file.networkTab');
const selectedIndex = tabValue === 'upload' ? 0 : 1;

return (
<div className={'flex flex-col gap-2 p-2'}>
<ViewTabs
value={tabValue}
onChange={handleTabChange}
className={'min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2'}
>
<ViewTab iconPosition='start' color='inherit' label={uploadLabel} value='upload' />
<ViewTab iconPosition='start' color='inherit' label={embedLabel} value='embed' />
</ViewTabs>
<div className={'appflowy-scroller max-h-[400px] overflow-y-auto p-2'}>
<TabPanel className={'flex h-full w-full flex-col'} index={0} value={selectedIndex}>
<FileDropzone
accept="application/pdf,.pdf"
multiple={true}
placeholder={
<span>
Click to upload or drag and drop PDF files
<span className={'text-text-action'}> {t('document.plugins.photoGallery.browserLayout')}</span>
</span>
}
onChange={handleChangeUploadFiles}
loading={uploading}
/>
</TabPanel>
<TabPanel className={'flex h-full w-full flex-col'} index={1} value={selectedIndex}>
<EmbedLink onDone={handleInsertEmbedLink} defaultLink={defaultLink} placeholder={'Embed a PDF link'} />
</TabPanel>
</div>
</div>
);
}

export default PDFBlockPopoverContent;
3 changes: 3 additions & 0 deletions src/components/editor/components/block-popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { calculateOptimalOrigins, Origins, Popover } from '@/components/_shared/
import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext';
import FileBlockPopoverContent from '@/components/editor/components/block-popover/FileBlockPopoverContent';
import ImageBlockPopoverContent from '@/components/editor/components/block-popover/ImageBlockPopoverContent';
import PDFBlockPopoverContent from '@/components/editor/components/block-popover/PDFBlockPopoverContent';
import { useEditorContext } from '@/components/editor/EditorContext';

import MathEquationPopoverContent from './MathEquationPopoverContent';
Expand Down Expand Up @@ -50,6 +51,8 @@ function BlockPopover() {
switch (type) {
case BlockType.FileBlock:
return <FileBlockPopoverContent blockId={blockId} onClose={handleClose} />;
case BlockType.PDFBlock:
return <PDFBlockPopoverContent blockId={blockId} onClose={handleClose} />;
case BlockType.ImageBlock:
return <ImageBlockPopoverContent blockId={blockId} onClose={handleClose} />;
case BlockType.EquationBlock:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/react';
import { withContainer } from '../../../../../../.storybook/decorators';
import { AIMeetingBlock } from './AIMeetingBlock';
import '../../../editor.scss';

const meta = {
title: 'Editor/Blocks/AIMeetingBlock',
component: AIMeetingBlock,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
decorators: [withContainer({ padding: '20px', maxWidth: '800px' })],
argTypes: {
node: {
description: 'The AI Meeting block node',
control: 'object',
},
},
} satisfies Meta<typeof AIMeetingBlock>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
node: {
type: 'ai_meeting',
blockId: 'ai-meeting-1',
children: [],
data: {
title: 'Weekly Team Sync',
},
},
children: [],
},
};

export const NoTitle: Story = {
args: {
node: {
type: 'ai_meeting',
blockId: 'ai-meeting-2',
children: [],
data: {},
},
children: [],
},
};

export const LongTitle: Story = {
args: {
node: {
type: 'ai_meeting',
blockId: 'ai-meeting-3',
children: [],
data: {
title: 'Quarterly Business Review Meeting with All Stakeholders and Department Heads - Q4 2025',
},
},
children: [],
},
};

export const ShortTitle: Story = {
args: {
node: {
type: 'ai_meeting',
blockId: 'ai-meeting-4',
children: [],
data: {
title: 'Standup',
},
},
children: [],
},
};

export const EmptyTitle: Story = {
args: {
node: {
type: 'ai_meeting',
blockId: 'ai-meeting-5',
children: [],
data: {
title: ' ',
},
},
children: [],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { AIMeetingNode, EditorElementProps } from '@/components/editor/editor.type';
import { forwardRef, memo } from 'react';

export const AIMeetingBlock = memo(
forwardRef<HTMLDivElement, EditorElementProps<AIMeetingNode>>(
({ node, children: _children, ...attributes }, ref) => {
const { data } = node;

const title = data?.title?.trim() || 'AI Meeting';

return (
<div
{...attributes}
ref={ref}
className={`${attributes.className ?? ''} ai-meeting-block my-2 overflow-hidden rounded-2xl bg-fill-list-active`}
contentEditable={false}
>
<div className="px-4 py-4">
<h2 className="text-3xl font-semibold text-text-primary">
{title}
</h2>
</div>

<div className="mx-0.5 mb-0.5 rounded-2xl bg-bg-body">
<div className="flex flex-col items-center justify-center px-8 py-10">
<p className="text-base text-text-secondary">
This content isn&apos;t supported on the web version yet.
</p>
<p className="text-base text-text-secondary">
Please switch to the desktop or mobile app to view this content.
</p>
</div>
</div>
</div>
);
}
)
);

AIMeetingBlock.displayName = 'AIMeetingBlock';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './AIMeetingBlock';
Loading
Loading