diff --git a/src/application/types.ts b/src/application/types.ts index 8b4e7d4c3..b48187d30 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -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 { @@ -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, diff --git a/src/assets/icons/pdf.svg b/src/assets/icons/pdf.svg new file mode 100644 index 000000000..901a43d13 --- /dev/null +++ b/src/assets/icons/pdf.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx new file mode 100644 index 000000000..9947292f5 --- /dev/null +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -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 => { + 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 ( +
+ + + + +
+ + + Click to upload or drag and drop PDF files + {t('document.plugins.photoGallery.browserLayout')} + + } + onChange={handleChangeUploadFiles} + loading={uploading} + /> + + + + +
+
+ ); +} + +export default PDFBlockPopoverContent; diff --git a/src/components/editor/components/block-popover/index.tsx b/src/components/editor/components/block-popover/index.tsx index 1c2d15a56..0c3dab0be 100644 --- a/src/components/editor/components/block-popover/index.tsx +++ b/src/components/editor/components/block-popover/index.tsx @@ -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'; @@ -50,6 +51,8 @@ function BlockPopover() { switch (type) { case BlockType.FileBlock: return ; + case BlockType.PDFBlock: + return ; case BlockType.ImageBlock: return ; case BlockType.EquationBlock: diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.stories.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.stories.tsx new file mode 100644 index 000000000..5825a47b9 --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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: [], + }, +}; diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx new file mode 100644 index 000000000..f513f5a47 --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -0,0 +1,40 @@ +import { AIMeetingNode, EditorElementProps } from '@/components/editor/editor.type'; +import { forwardRef, memo } from 'react'; + +export const AIMeetingBlock = memo( + forwardRef>( + ({ node, children: _children, ...attributes }, ref) => { + const { data } = node; + + const title = data?.title?.trim() || 'AI Meeting'; + + return ( +
+
+

+ {title} +

+
+ +
+
+

+ This content isn't supported on the web version yet. +

+

+ Please switch to the desktop or mobile app to view this content. +

+
+
+
+ ); + } + ) +); + +AIMeetingBlock.displayName = 'AIMeetingBlock'; diff --git a/src/components/editor/components/blocks/ai-meeting/index.ts b/src/components/editor/components/blocks/ai-meeting/index.ts new file mode 100644 index 000000000..a87503fba --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/index.ts @@ -0,0 +1 @@ +export * from './AIMeetingBlock'; diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx new file mode 100644 index 000000000..b83a9733c --- /dev/null +++ b/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx @@ -0,0 +1,174 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Slate } from 'slate-react'; +import { createEditor } from 'slate'; +import { withContainer } from '../../../../../../.storybook/decorators'; +import { PDFBlock } from './PDFBlock'; +import { EditorContext } from '@/components/editor/EditorContext'; +import { BlockPopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; +import '../../../editor.scss'; + +const mockEditorContext = { + uploadFile: async (file: File) => { + console.log('Mock upload:', file.name); + return 'https://example.com/uploaded.pdf'; + }, + workspaceId: 'mock-workspace', + viewId: 'mock-view', +}; + +const mockPopoverContext = { + open: false, + anchorEl: null, + close: () => { + // Mock close function + }, + openPopover: (blockId: string, _type: unknown, _anchor: HTMLElement) => { + console.log('Open popover for block:', blockId); + }, + type: null, + blockId: null, +}; + +// eslint-disable-next-line react/display-name +const WithEditorContexts = (Story: React.ComponentType) => { + const editor = React.useMemo(() => createEditor(), []); + const [value] = React.useState([ + { + type: 'paragraph', + children: [{ text: '' }], + }, + ]); + + return ( + + + + + + + + ); +}; + +const meta = { + title: 'Editor/Blocks/PDFBlock', + component: PDFBlock, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [WithEditorContexts, withContainer({ padding: '20px', maxWidth: '800px' })], + argTypes: { + node: { + description: 'The PDF block node', + control: 'object', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-1', + children: [], + data: { + name: 'Project Proposal.pdf', + url: 'https://example.com/proposal.pdf', + }, + }, + children: [], + }, +}; + +export const NoName: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-2', + children: [], + data: { + url: 'https://example.com/document.pdf', + }, + }, + children: [], + }, +}; + +export const LongFilename: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-3', + children: [], + data: { + name: 'Very Long Document Name That Should Wrap Properly in the UI Display Area Without Breaking Layout Q4 2025 Final Version.pdf', + url: 'https://example.com/long-document.pdf', + }, + }, + children: [], + }, +}; + +export const ShortFilename: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-4', + children: [], + data: { + name: 'Doc.pdf', + url: 'https://example.com/doc.pdf', + }, + }, + children: [], + }, +}; + +export const NoExtension: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-5', + children: [], + data: { + name: 'Meeting Notes', + url: 'https://example.com/notes.pdf', + }, + }, + children: [], + }, +}; + +export const EmptyName: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-6', + children: [], + data: { + name: ' ', + url: 'https://example.com/doc.pdf', + }, + }, + children: [], + }, +}; + +export const NoURL: Story = { + args: { + node: { + type: 'pdf', + blockId: 'pdf-7', + children: [], + data: { + name: 'Pending Upload.pdf', + }, + }, + children: [], + }, +}; diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.tsx new file mode 100644 index 000000000..ec7085cd5 --- /dev/null +++ b/src/components/editor/components/blocks/pdf/PDFBlock.tsx @@ -0,0 +1,198 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { BlockType, FieldURLType, PDFBlockData } from '@/application/types'; +import { ReactComponent as PDFIcon } from '@/assets/icons/pdf.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/icons/regenerate.svg'; +import { notify } from '@/components/_shared/notify'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; +import FileToolbar from '@/components/editor/components/blocks/file/FileToolbar'; +import { EditorElementProps, FileNode, PDFNode } from '@/components/editor/editor.type'; +import { FileHandler } from '@/utils/file'; +import { CircularProgress, IconButton, Tooltip } from '@mui/material'; +import React, { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +export const PDFBlock = memo( + forwardRef>( + ({ node, children, ...attributes }, ref) => { + const { blockId, data } = node; + const { uploadFile } = useEditorContext(); + const editor = useSlateStatic() as YjsEditor; + const [needRetry, setNeedRetry] = useState(false); + const fileHandlerRef = useRef(new FileHandler()); + const [localUrl, setLocalUrl] = useState(undefined); + const [loading, setLoading] = useState(false); + const { url, name, retry_local_url } = data || {}; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const emptyRef = useRef(null); + const [showToolbar, setShowToolbar] = useState(false); + + const hasContent = url || needRetry; + + const className = [ + 'w-full', + url || !readOnly ? 'cursor-pointer' : 'text-text-secondary', + attributes.className, + ] + .filter(Boolean) + .join(' '); + + const { openPopover } = usePopoverContext(); + + const openUploadPopover = useCallback(() => { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.PDFBlock, emptyRef.current); + } + }, [blockId, openPopover, readOnly]); + + const openPDFInNewTab = useCallback(() => { + const link = url || localUrl; + + if (link) { + window.open(link, '_blank'); + } + }, [url, localUrl]); + + const handleClick = useCallback(async () => { + try { + if (!url && !needRetry) { + openUploadPopover(); + return; + } + + openPDFInNewTab(); + } catch (e: unknown) { + notify.error((e as Error).message); + } + }, [url, needRetry, openUploadPopover, openPDFInNewTab]); + + useEffect(() => { + if (readOnly) return; + void (async () => { + if (retry_local_url) { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + + setLocalUrl(fileData?.url); + setNeedRetry(!!fileData); + } else { + setNeedRetry(false); + } + })(); + }, [readOnly, retry_local_url]); + + const uploadFileRemote = useCallback( + async (file: File) => { + try { + if (uploadFile) { + return await uploadFile(file); + } + } catch (e: unknown) { + return; + } + }, + [uploadFile] + ); + + const handleRetry = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!retry_local_url) return; + + setLoading(true); + try { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + const file = fileData?.file; + + if (!file) { + notify.error('File not found. Please upload again.'); + return; + } + + const url = await uploadFileRemote(file); + + if (!url) { + notify.error('Upload failed. Please try again.'); + return; + } + + await fileHandlerRef.current.cleanup(retry_local_url); + CustomEditor.setBlockData(editor, blockId, { + url, + name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + retry_local_url: '', + } as PDFBlockData); + } catch (e: unknown) { + notify.error((e as Error).message || 'Failed to retry upload. Please try again.'); + } finally { + setLoading(false); + } + }, + [blockId, editor, name, retry_local_url, uploadFileRemote] + ); + + return ( +
{ + if (!url) return; + setShowToolbar(true); + }} + onMouseLeave={() => setShowToolbar(false)} + onClick={handleClick} + > +
+
+ +
+ +
+ {hasContent ? ( +
+
{name?.trim() || 'PDF Document'}
+ {needRetry &&
Upload failed
} +
+ ) : ( +
Upload or embed a PDF
+ )} +
+ + {needRetry && + (loading ? ( + + ) : ( + + + + + + ))} + {showToolbar && url && ( + + )} +
+
+ {children} +
+
+ ); + } + ) +); + +PDFBlock.displayName = 'PDFBlock'; diff --git a/src/components/editor/components/blocks/pdf/index.ts b/src/components/editor/components/blocks/pdf/index.ts new file mode 100644 index 000000000..84c923c0d --- /dev/null +++ b/src/components/editor/components/blocks/pdf/index.ts @@ -0,0 +1 @@ +export * from './PDFBlock'; diff --git a/src/components/editor/components/element/Element.tsx b/src/components/editor/components/element/Element.tsx index 6c1ab5358..0fb215742 100644 --- a/src/components/editor/components/element/Element.tsx +++ b/src/components/editor/components/element/Element.tsx @@ -13,9 +13,11 @@ import { CodeBlock } from '@/components/editor/components/blocks/code'; import { Column, Columns } from '@/components/editor/components/blocks/columns'; import { DatabaseBlock } from '@/components/editor/components/blocks/database'; import { DividerNode } from '@/components/editor/components/blocks/divider'; +import { AIMeetingBlock } from '@/components/editor/components/blocks/ai-meeting'; import { FileBlock } from '@/components/editor/components/blocks/file'; import { GalleryBlock } from '@/components/editor/components/blocks/gallery'; import { Heading } from '@/components/editor/components/blocks/heading'; +import { PDFBlock } from '@/components/editor/components/blocks/pdf'; import { ImageBlock } from '@/components/editor/components/blocks/image'; import { LinkPreview } from '@/components/editor/components/blocks/link-preview'; import { MathEquation } from '@/components/editor/components/blocks/math-equation'; @@ -212,6 +214,10 @@ export const Element = ({ return Columns; case BlockType.ColumnBlock: return Column; + case BlockType.AIMeetingBlock: + return AIMeetingBlock; + case BlockType.PDFBlock: + return PDFBlock; default: return BlockNotFound; } diff --git a/src/components/editor/editor.type.ts b/src/components/editor/editor.type.ts index e27f21198..d1130aa11 100644 --- a/src/components/editor/editor.type.ts +++ b/src/components/editor/editor.type.ts @@ -2,6 +2,7 @@ import { HTMLAttributes } from 'react'; import { Element } from 'slate'; import { + AIMeetingBlockData, BlockType, CalloutBlockData, CodeBlockData, @@ -9,6 +10,7 @@ import { ImageBlockData, MathEquationBlockData, NumberedListBlockData, + PDFBlockData, TodoListBlockData, ToggleListBlockData, YjsEditorKey, @@ -190,6 +192,16 @@ export interface ColumnNode extends BlockNode { data: ColumnNodeData; } +export interface AIMeetingNode extends BlockNode { + type: BlockType.AIMeetingBlock; + data: AIMeetingBlockData; +} + +export interface PDFNode extends BlockNode { + type: BlockType.PDFBlock; + data: PDFBlockData; +} + export interface EditorElementProps extends HTMLAttributes { node: T; }