From 7c624ce5c5e15bffed82615581e1b032e7ff997b Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 1 Dec 2025 20:46:19 +0800 Subject: [PATCH 1/6] feat: support simple ai meeting and pdf block --- src/application/types.ts | 14 ++ src/assets/icons/pdf.svg | 4 + .../block-popover/PDFBlockPopoverContent.tsx | 187 +++++++++++++++++ .../editor/components/block-popover/index.tsx | 3 + .../ai-meeting/AIMeetingBlock.stories.tsx | 91 ++++++++ .../blocks/ai-meeting/AIMeetingBlock.tsx | 37 ++++ .../components/blocks/ai-meeting/index.ts | 1 + .../blocks/pdf/PDFBlock.stories.tsx | 171 +++++++++++++++ .../editor/components/blocks/pdf/PDFBlock.tsx | 195 ++++++++++++++++++ .../editor/components/blocks/pdf/index.ts | 1 + .../editor/components/element/Element.tsx | 6 + src/components/editor/editor.type.ts | 12 ++ 12 files changed, 722 insertions(+) create mode 100644 src/assets/icons/pdf.svg create mode 100644 src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx create mode 100644 src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.stories.tsx create mode 100644 src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx create mode 100644 src/components/editor/components/blocks/ai-meeting/index.ts create mode 100644 src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx create mode 100644 src/components/editor/components/blocks/pdf/PDFBlock.tsx create mode 100644 src/components/editor/components/blocks/pdf/index.ts 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..503f2a23b --- /dev/null +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -0,0 +1,187 @@ +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(url: string) { + const urlObj = new URL(url); + const name = urlObj.pathname.split('/').pop(); + + return name; +} + +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: any) { + return; + } + }, + [uploadFile] + ); + + const getData = useCallback(async (file: File, remoteUrl?: string) => { + const data = { + url: remoteUrl, + name: file.name, + uploaded_at: Date.now(), + url_type: FieldURLType.Upload, + } as PDFBlockData; + + if (!remoteUrl) { + const fileHandler = new FileHandler(); + const res = await fileHandler.handleFileUpload(file); + + data.retry_local_url = res.id; + } + + return data; + }, []); + + const insertPDFBlock = useCallback( + async (file: File) => { + const url = await uploadFileRemote(file); + const data = await getData(file, url); + + CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data); + }, + [blockId, editor, getData, uploadFileRemote] + ); + + const handleChangeUploadFiles = useCallback( + async (files: File[]) => { + if (!files.length) return; + + setUploading(true); + try { + const [file, ...otherFiles] = files; + const url = await uploadFileRemote(file); + const data = await getData(file, url); + + CustomEditor.setBlockData(editor, blockId, data); + + for (const file of otherFiles.reverse()) { + await insertPDFBlock(file); + } + + onClose(); + } finally { + setUploading(false); + } + }, + [blockId, editor, getData, insertPDFBlock, onClose, uploadFileRemote] + ); + + const tabOptions = useMemo(() => { + return [ + { + key: 'upload', + label: t('button.upload'), + panel: ( + + Click to upload or drag and drop PDF files + {t('document.plugins.photoGallery.browserLayout')} + + } + onChange={handleChangeUploadFiles} + loading={uploading} + /> + ), + }, + { + key: 'embed', + label: t('document.plugins.file.networkTab'), + panel: ( + + ), + }, + ]; + }, [entry, handleChangeUploadFiles, handleInsertEmbedLink, t, uploading]); + + const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + + return ( +
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + +
+ {tabOptions.map((tab, index) => { + const { key, panel } = tab; + + return ( + + {panel} + + ); + })} +
+
+ ); +} + +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..00a138c0c --- /dev/null +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -0,0 +1,37 @@ +import { AIMeetingNode, EditorElementProps } from '@/components/editor/editor.type'; +import { forwardRef, memo } from 'react'; + +export const AIMeetingBlock = memo( + forwardRef>( + ({ node, children, ...attributes }, ref) => { + const { data } = node; + + const title = data?.title?.trim() || 'AI Meeting'; + + return ( +
+
+

+ {title} +

+
+ +
+
+

+ Please view the content on desktop application +

+
+
+
+ ); + } + ) +); + +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..f921d14c7 --- /dev/null +++ b/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx @@ -0,0 +1,171 @@ +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: () => {}, + openPopover: (blockId: string, type: any, anchor: HTMLElement) => { + console.log('Open popover for block:', blockId); + }, + type: null, + blockId: null, +}; + +const withEditorContexts = (Story: any) => { + 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..6b3089b36 --- /dev/null +++ b/src/components/editor/components/blocks/pdf/PDFBlock.tsx @@ -0,0 +1,195 @@ +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, PDFNode } from '@/components/editor/editor.type'; +import { FileHandler } from '@/utils/file'; +import { CircularProgress, IconButton, Tooltip } from '@mui/material'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, 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 fileHandler = useMemo(() => new FileHandler(), []); + const [localUrl, setLocalUrl] = useState(undefined); + const [loading, setLoading] = useState(false); + const { url: dataUrl, name, retry_local_url } = useMemo(() => data || {}, [data]); + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const emptyRef = useRef(null); + const [showToolbar, setShowToolbar] = useState(false); + + const url = dataUrl; + + const className = useMemo(() => { + const classList = ['w-full']; + + if (url) { + classList.push('cursor-pointer'); + } else { + classList.push('text-text-secondary'); + } + + if (attributes.className) { + classList.push(attributes.className); + } + + if (!readOnly) { + classList.push('cursor-pointer'); + } + + return classList.join(' '); + }, [attributes.className, readOnly, url]); + + const { openPopover } = usePopoverContext(); + + const handleClick = useCallback(async () => { + try { + if (!url && !needRetry) { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.PDFBlock, emptyRef.current); + } + + return; + } + + const link = url || localUrl; + + if (link) { + window.open(link, '_blank'); + } + } catch (e: any) { + notify.error(e.message); + } + }, [url, needRetry, localUrl, readOnly, openPopover, blockId]); + + useEffect(() => { + if (readOnly) return; + void (async () => { + if (retry_local_url) { + const fileData = await fileHandler.getStoredFile(retry_local_url); + + setLocalUrl(fileData?.url); + setNeedRetry(!!fileData); + } else { + setNeedRetry(false); + } + })(); + }, [readOnly, retry_local_url, fileHandler]); + + const uploadFileRemote = useCallback( + async (file: File) => { + try { + if (uploadFile) { + return await uploadFile(file); + } + } catch (e: any) { + return; + } + }, + [uploadFile] + ); + + const handleRetry = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!retry_local_url) return; + const fileData = await fileHandler.getStoredFile(retry_local_url); + const file = fileData?.file; + + if (!file) return; + + const url = await uploadFileRemote(file); + + if (!url) { + return; + } + + setLoading(true); + try { + await fileHandler.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) { + } finally { + setLoading(false); + } + }, + [blockId, editor, fileHandler, name, retry_local_url, uploadFileRemote] + ); + + return ( +
{ + if (!url) return; + setShowToolbar(true); + }} + onMouseLeave={() => setShowToolbar(false)} + onClick={handleClick} + > +
+
+ +
+ +
+ {url || needRetry ? ( +
+
{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; } From 0ba84b88560033320c125e2a920e1dcf9e2f8daa Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 1 Dec 2025 20:51:53 +0800 Subject: [PATCH 2/6] chore: update placeholder text --- .../editor/components/blocks/ai-meeting/AIMeetingBlock.tsx | 2 +- src/components/editor/components/blocks/pdf/PDFBlock.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx index 00a138c0c..dcc4989d1 100644 --- a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -24,7 +24,7 @@ export const AIMeetingBlock = memo(

- Please view the content on desktop application + Please use the desktop or mobile application to view the meeting content.

diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.tsx index 6b3089b36..f7f287485 100644 --- a/src/components/editor/components/blocks/pdf/PDFBlock.tsx +++ b/src/components/editor/components/blocks/pdf/PDFBlock.tsx @@ -145,7 +145,7 @@ export const PDFBlock = memo( onMouseLeave={() => setShowToolbar(false)} onClick={handleClick} > -
+
From 3690179fdb303639ed24803d85800b8b0346655e Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 1 Dec 2025 21:01:13 +0800 Subject: [PATCH 3/6] chore: code review --- .../block-popover/PDFBlockPopoverContent.tsx | 135 +++++++----------- .../editor/components/blocks/pdf/PDFBlock.tsx | 90 ++++++------ 2 files changed, 98 insertions(+), 127 deletions(-) diff --git a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx index 503f2a23b..40008a451 100644 --- a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -13,11 +13,14 @@ import { FileHandler } from '@/utils/file'; import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; -export function getFileName(url: string) { - const urlObj = new URL(url); - const name = urlObj.pathname.split('/').pop(); - - return name; +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 }) { @@ -66,32 +69,25 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose [uploadFile] ); - const getData = useCallback(async (file: File, remoteUrl?: string) => { - const data = { - url: remoteUrl, - name: file.name, - uploaded_at: Date.now(), - url_type: FieldURLType.Upload, - } as PDFBlockData; - - if (!remoteUrl) { - const fileHandler = new FileHandler(); - const res = await fileHandler.handleFileUpload(file); - - data.retry_local_url = res.id; - } - - return data; - }, []); - - const insertPDFBlock = useCallback( - async (file: File) => { + const processFileUpload = useCallback( + async (file: File): Promise => { const url = await uploadFileRemote(file); - const data = await getData(file, url); + 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; + } - CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data); + return data; }, - [blockId, editor, getData, uploadFileRemote] + [uploadFileRemote] ); const handleChangeUploadFiles = useCallback( @@ -101,13 +97,13 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose setUploading(true); try { const [file, ...otherFiles] = files; - const url = await uploadFileRemote(file); - const data = await getData(file, url); + const data = await processFileUpload(file); CustomEditor.setBlockData(editor, blockId, data); for (const file of otherFiles.reverse()) { - await insertPDFBlock(file); + const data = await processFileUpload(file); + CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data); } onClose(); @@ -115,46 +111,16 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose setUploading(false); } }, - [blockId, editor, getData, insertPDFBlock, onClose, uploadFileRemote] + [blockId, editor, onClose, processFileUpload] ); - const tabOptions = useMemo(() => { - return [ - { - key: 'upload', - label: t('button.upload'), - panel: ( - - Click to upload or drag and drop PDF files - {t('document.plugins.photoGallery.browserLayout')} - - } - onChange={handleChangeUploadFiles} - loading={uploading} - /> - ), - }, - { - key: 'embed', - label: t('document.plugins.file.networkTab'), - panel: ( - - ), - }, - ]; - }, [entry, handleChangeUploadFiles, handleInsertEmbedLink, t, uploading]); + const defaultLink = useMemo(() => { + return (entry?.[0]?.data as PDFBlockData | undefined)?.url; + }, [entry]); - const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + const uploadLabel = t('button.upload'); + const embedLabel = t('document.plugins.file.networkTab'); + const selectedIndex = tabValue === 'upload' ? 0 : 1; return (
@@ -163,22 +129,27 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose onChange={handleTabChange} className={'min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2'} > - {tabOptions.map((tab) => { - const { key, label } = tab; - - return ; - })} + +
- {tabOptions.map((tab, index) => { - const { key, panel } = tab; - - return ( - - {panel} - - ); - })} + + + Click to upload or drag and drop PDF files + {t('document.plugins.photoGallery.browserLayout')} + + } + onChange={handleChangeUploadFiles} + loading={uploading} + /> + + + +
); diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.tsx index f7f287485..f0fff149b 100644 --- a/src/components/editor/components/blocks/pdf/PDFBlock.tsx +++ b/src/components/editor/components/blocks/pdf/PDFBlock.tsx @@ -21,63 +21,57 @@ export const PDFBlock = memo( const { uploadFile } = useEditorContext(); const editor = useSlateStatic() as YjsEditor; const [needRetry, setNeedRetry] = useState(false); - const fileHandler = useMemo(() => new FileHandler(), []); + const fileHandlerRef = useRef(new FileHandler()); const [localUrl, setLocalUrl] = useState(undefined); const [loading, setLoading] = useState(false); - const { url: dataUrl, name, retry_local_url } = useMemo(() => data || {}, [data]); + 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 url = dataUrl; + const hasContent = url || needRetry; - const className = useMemo(() => { - const classList = ['w-full']; + const className = [ + 'w-full', + url || !readOnly ? 'cursor-pointer' : 'text-text-secondary', + attributes.className, + ] + .filter(Boolean) + .join(' '); - if (url) { - classList.push('cursor-pointer'); - } else { - classList.push('text-text-secondary'); - } + const { openPopover } = usePopoverContext(); - if (attributes.className) { - classList.push(attributes.className); + const openUploadPopover = useCallback(() => { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.PDFBlock, emptyRef.current); } + }, [blockId, openPopover, readOnly]); - if (!readOnly) { - classList.push('cursor-pointer'); + const openPDFInNewTab = useCallback(() => { + const link = url || localUrl; + if (link) { + window.open(link, '_blank'); } - - return classList.join(' '); - }, [attributes.className, readOnly, url]); - - const { openPopover } = usePopoverContext(); + }, [url, localUrl]); const handleClick = useCallback(async () => { try { if (!url && !needRetry) { - if (emptyRef.current && !readOnly) { - openPopover(blockId, BlockType.PDFBlock, emptyRef.current); - } - + openUploadPopover(); return; } - const link = url || localUrl; - - if (link) { - window.open(link, '_blank'); - } + openPDFInNewTab(); } catch (e: any) { notify.error(e.message); } - }, [url, needRetry, localUrl, readOnly, openPopover, blockId]); + }, [url, needRetry, openUploadPopover, openPDFInNewTab]); useEffect(() => { if (readOnly) return; void (async () => { if (retry_local_url) { - const fileData = await fileHandler.getStoredFile(retry_local_url); + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); setLocalUrl(fileData?.url); setNeedRetry(!!fileData); @@ -85,7 +79,7 @@ export const PDFBlock = memo( setNeedRetry(false); } })(); - }, [readOnly, retry_local_url, fileHandler]); + }, [readOnly, retry_local_url]); const uploadFileRemote = useCallback( async (file: File) => { @@ -104,20 +98,25 @@ export const PDFBlock = memo( async (e: React.MouseEvent) => { e.stopPropagation(); if (!retry_local_url) return; - const fileData = await fileHandler.getStoredFile(retry_local_url); - const file = fileData?.file; - if (!file) return; + setLoading(true); + try { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + const file = fileData?.file; - const url = await uploadFileRemote(file); + if (!file) { + notify.error('File not found. Please upload again.'); + return; + } - if (!url) { - return; - } + const url = await uploadFileRemote(file); - setLoading(true); - try { - await fileHandler.cleanup(retry_local_url); + if (!url) { + notify.error('Upload failed. Please try again.'); + return; + } + + await fileHandlerRef.current.cleanup(retry_local_url); CustomEditor.setBlockData(editor, blockId, { url, name, @@ -125,12 +124,13 @@ export const PDFBlock = memo( url_type: FieldURLType.Upload, retry_local_url: '', } as PDFBlockData); - } catch (e) { + } catch (e: any) { + notify.error(e.message || 'Failed to retry upload. Please try again.'); } finally { setLoading(false); } }, - [blockId, editor, fileHandler, name, retry_local_url, uploadFileRemote] + [blockId, editor, name, retry_local_url, uploadFileRemote] ); return ( @@ -145,13 +145,13 @@ export const PDFBlock = memo( onMouseLeave={() => setShowToolbar(false)} onClick={handleClick} > -
+
- {url || needRetry ? ( + {hasContent ? (
{name?.trim() || 'PDF Document'}
{needRetry &&
Upload failed
} From aa8673a8b1282a710d3a75f60a623a2868f08112 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Mon, 1 Dec 2025 21:46:00 +0800 Subject: [PATCH 4/6] chore: lint code --- .../block-popover/PDFBlockPopoverContent.tsx | 5 ++- .../blocks/ai-meeting/AIMeetingBlock.tsx | 2 +- .../blocks/pdf/PDFBlock.stories.tsx | 13 +++++--- .../editor/components/blocks/pdf/PDFBlock.tsx | 31 ++++++++++--------- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx index 40008a451..9947292f5 100644 --- a/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/PDFBlockPopoverContent.tsx @@ -17,6 +17,7 @@ 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; @@ -62,7 +63,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose if (uploadFile) { return await uploadFile(file); } - } catch (e: any) { + } catch (e: unknown) { return; } }, @@ -82,6 +83,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose if (!url) { const fileHandler = new FileHandler(); const res = await fileHandler.handleFileUpload(file); + data.retry_local_url = res.id; } @@ -103,6 +105,7 @@ function PDFBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose for (const file of otherFiles.reverse()) { const data = await processFileUpload(file); + CustomEditor.addBelowBlock(editor, blockId, BlockType.PDFBlock, data); } diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx index dcc4989d1..356b1e246 100644 --- a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -3,7 +3,7 @@ import { forwardRef, memo } from 'react'; export const AIMeetingBlock = memo( forwardRef>( - ({ node, children, ...attributes }, ref) => { + ({ node, children: _children, ...attributes }, ref) => { const { data } = node; const title = data?.title?.trim() || 'AI Meeting'; diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx index f921d14c7..b83a9733c 100644 --- a/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx +++ b/src/components/editor/components/blocks/pdf/PDFBlock.stories.tsx @@ -20,15 +20,18 @@ const mockEditorContext = { const mockPopoverContext = { open: false, anchorEl: null, - close: () => {}, - openPopover: (blockId: string, type: any, anchor: HTMLElement) => { + close: () => { + // Mock close function + }, + openPopover: (blockId: string, _type: unknown, _anchor: HTMLElement) => { console.log('Open popover for block:', blockId); }, type: null, blockId: null, }; -const withEditorContexts = (Story: any) => { +// eslint-disable-next-line react/display-name +const WithEditorContexts = (Story: React.ComponentType) => { const editor = React.useMemo(() => createEditor(), []); const [value] = React.useState([ { @@ -39,7 +42,7 @@ const withEditorContexts = (Story: any) => { return ( - + @@ -55,7 +58,7 @@ const meta = { layout: 'centered', }, tags: ['autodocs'], - decorators: [withEditorContexts, withContainer({ padding: '20px', maxWidth: '800px' })], + decorators: [WithEditorContexts, withContainer({ padding: '20px', maxWidth: '800px' })], argTypes: { node: { description: 'The PDF block node', diff --git a/src/components/editor/components/blocks/pdf/PDFBlock.tsx b/src/components/editor/components/blocks/pdf/PDFBlock.tsx index f0fff149b..ec7085cd5 100644 --- a/src/components/editor/components/blocks/pdf/PDFBlock.tsx +++ b/src/components/editor/components/blocks/pdf/PDFBlock.tsx @@ -7,10 +7,10 @@ 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, PDFNode } from '@/components/editor/editor.type'; +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, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; import { Element } from 'slate'; import { useReadOnly, useSlateStatic } from 'slate-react'; @@ -49,6 +49,7 @@ export const PDFBlock = memo( const openPDFInNewTab = useCallback(() => { const link = url || localUrl; + if (link) { window.open(link, '_blank'); } @@ -62,8 +63,8 @@ export const PDFBlock = memo( } openPDFInNewTab(); - } catch (e: any) { - notify.error(e.message); + } catch (e: unknown) { + notify.error((e as Error).message); } }, [url, needRetry, openUploadPopover, openPDFInNewTab]); @@ -87,7 +88,7 @@ export const PDFBlock = memo( if (uploadFile) { return await uploadFile(file); } - } catch (e: any) { + } catch (e: unknown) { return; } }, @@ -124,8 +125,8 @@ export const PDFBlock = memo( url_type: FieldURLType.Upload, retry_local_url: '', } as PDFBlockData); - } catch (e: any) { - notify.error(e.message || 'Failed to retry upload. Please try again.'); + } catch (e: unknown) { + notify.error((e as Error).message || 'Failed to retry upload. Please try again.'); } finally { setLoading(false); } @@ -173,13 +174,15 @@ export const PDFBlock = memo( ))} {showToolbar && url && ( )}
From 10d9565ecd8831078becca02af0236b4ff083bc4 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 2 Dec 2025 13:23:42 +0800 Subject: [PATCH 5/6] chore: update placeholder text --- .../editor/components/blocks/ai-meeting/AIMeetingBlock.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx index 356b1e246..5986b2e78 100644 --- a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -24,7 +24,10 @@ export const AIMeetingBlock = memo(

- Please use the desktop or mobile application to view the meeting content. + This content isn't supported on the web version yet. +

+

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

From 504e54e04a1db9a2ba86e271c82503301b3f06f4 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Tue, 2 Dec 2025 14:11:56 +0800 Subject: [PATCH 6/6] fix: lint error --- .../editor/components/blocks/ai-meeting/AIMeetingBlock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx index 5986b2e78..f513f5a47 100644 --- a/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx +++ b/src/components/editor/components/blocks/ai-meeting/AIMeetingBlock.tsx @@ -24,7 +24,7 @@ export const AIMeetingBlock = memo(

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

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