From ae2444c1907e130e9ecf0beb54ddfc6d8b5a269e Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Wed, 26 Feb 2025 17:19:23 +0800 Subject: [PATCH] feat: support embed video on document (#47) --- package.json | 1 + pnpm-lock.yaml | 28 ++++- src/@types/translations/en.json | 8 +- src/application/types.ts | 15 +++ .../VideoBlockPopoverContent.tsx | 94 ++++++++++++++++ .../editor/components/block-popover/index.tsx | 22 ++-- .../components/blocks/image/ImageResizer.tsx | 14 ++- .../components/blocks/video/VideoBlock.tsx | 85 +++++++++++++++ .../components/blocks/video/VideoEmpty.tsx | 29 +++++ .../components/blocks/video/VideoRender.tsx | 100 ++++++++++++++++++ .../components/blocks/video/VideoToolbar.tsx | 64 +++++++++++ .../editor/components/blocks/video/index.ts | 1 + .../editor/components/element/Element.tsx | 27 ++--- .../components/element/UnSupportedBlock.tsx | 4 +- .../panels/slash-panel/SlashPanel.tsx | 59 +++++++---- src/components/editor/editor.type.ts | 8 +- src/components/editor/plugins/withPasted.ts | 56 ++++++---- 17 files changed, 537 insertions(+), 78 deletions(-) create mode 100644 src/components/editor/components/block-popover/VideoBlockPopoverContent.tsx create mode 100644 src/components/editor/components/blocks/video/VideoBlock.tsx create mode 100644 src/components/editor/components/blocks/video/VideoEmpty.tsx create mode 100644 src/components/editor/components/blocks/video/VideoRender.tsx create mode 100644 src/components/editor/components/blocks/video/VideoToolbar.tsx create mode 100644 src/components/editor/components/blocks/video/index.ts diff --git a/package.json b/package.json index 4774a0af..b50eee92 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-i18next": "^14.1.0", "react-katex": "^3.0.1", "react-measure": "^2.5.2", + "react-player": "^2.16.0", "react-redux": "^8.0.5", "react-router-dom": "^6.22.3", "react-swipeable-views": "^0.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a60bbd92..f0693fd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - dependencies: '@appflowyinc/editor': specifier: ^0.0.40 @@ -194,6 +190,9 @@ dependencies: react-measure: specifier: ^2.5.2 version: 2.5.2(react-dom@18.2.0)(react@18.2.0) + react-player: + specifier: ^2.16.0 + version: 2.16.0(react@18.2.0) react-redux: specifier: ^8.0.5 version: 8.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) @@ -10311,6 +10310,10 @@ packages: wrap-ansi: 7.0.0 dev: true + /load-script@1.0.0: + resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} + dev: false + /loader-runner@4.3.0: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} @@ -12181,6 +12184,19 @@ packages: warning: 4.0.3 dev: false + /react-player@2.16.0(react@18.2.0): + resolution: {integrity: sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==} + peerDependencies: + react: '>=16.6.0' + dependencies: + deepmerge: 4.3.1 + load-script: 1.0.0 + memoize-one: 5.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-fast-compare: 3.2.2 + dev: false + /react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==} peerDependencies: @@ -14729,3 +14745,7 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 3898fbe2..8954855a 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -3064,5 +3064,11 @@ "openInBrowser": "Open in browser", "openInApp": "Open in app", "comments": "Comments", - "duplicateAsTemplate": "Duplicate as template" + "duplicateAsTemplate": "Duplicate as template", + "embedVideo": "Embed video", + "embedAVideo": "Embed a video", + "embedLink": "Embed link", + "embedVideoLinkPlaceholder": "Paste a video link here.", + "videoSupported": "Supported services: YouTube, Vimeo, and more.", + "copiedVideoLink": "Video original link copied to clipboard" } diff --git a/src/application/types.ts b/src/application/types.ts index 2309b08e..19d1b3b8 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -26,6 +26,7 @@ export enum BlockType { CalloutBlock = 'callout', DividerBlock = 'divider', ImageBlock = 'image', + VideoBlock = 'video', GridBlock = 'grid', BoardBlock = 'board', CalendarBlock = 'calendar', @@ -124,6 +125,20 @@ export interface ImageBlockData extends BlockData { retry_local_url?: string; } +export enum VideoType { + Local = 0, + Internal = 1, + External = 2, +} + +export interface VideoBlockData extends BlockData { + url?: string; + width?: number; + height?: number; + align?: AlignType; + video_type?: VideoType; +} + export enum GalleryLayout { Carousel = 0, Grid = 1, diff --git a/src/components/editor/components/block-popover/VideoBlockPopoverContent.tsx b/src/components/editor/components/block-popover/VideoBlockPopoverContent.tsx new file mode 100644 index 00000000..919acf2b --- /dev/null +++ b/src/components/editor/components/block-popover/VideoBlockPopoverContent.tsx @@ -0,0 +1,94 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { VideoBlockData, VideoType } from '@/application/types'; +import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; +import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; + +function VideoBlockPopoverContent({ + blockId, + onClose, +}: { + blockId: string; + onClose: () => void; +}) { + const editor = useSlateStatic() as YjsEditor; + const { t } = useTranslation(); + + const [tabValue, setTabValue] = React.useState('embed'); + const entry = useMemo(() => { + try { + return findSlateEntryByBlockId(editor, blockId); + } catch(e) { + return null; + } + }, [blockId, editor]); + const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => { + setTabValue(newValue); + }, []); + + const handleInsertEmbedLink = useCallback((url: string) => { + CustomEditor.setBlockData(editor, blockId, { + url, + video_type: VideoType.External, + } as VideoBlockData); + onClose(); + }, [blockId, editor, onClose]); + const tabOptions = useMemo(() => { + return [ + { + key: 'embed', + label: t('embedLink'), + panel:
+
{t('videoSupported')}
+
, + }, + ]; + }, [entry, handleInsertEmbedLink, t]); + 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 VideoBlockPopoverContent; \ No newline at end of file diff --git a/src/components/editor/components/block-popover/index.tsx b/src/components/editor/components/block-popover/index.tsx index 6d4881ea..9d86abaf 100644 --- a/src/components/editor/components/block-popover/index.tsx +++ b/src/components/editor/components/block-popover/index.tsx @@ -9,6 +9,7 @@ import { useEditorContext } from '@/components/editor/EditorContext'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { ReactEditor, useSlateStatic } from 'slate-react'; import MathEquationPopoverContent from './MathEquationPopoverContent'; +import VideoBlockPopoverContent from './VideoBlockPopoverContent'; const defaultOrigins: Origins = { anchorOrigin: { @@ -21,7 +22,7 @@ const defaultOrigins: Origins = { }, }; -function BlockPopover () { +function BlockPopover() { const { open, anchorEl, @@ -35,7 +36,7 @@ function BlockPopover () { const handleClose = useCallback(() => { window.getSelection()?.removeAllRanges(); - if (!blockId) return; + if(!blockId) return; const [, path] = findSlateEntryByBlockId(editor, blockId); @@ -45,8 +46,8 @@ function BlockPopover () { }, [blockId, close, editor]); const content = useMemo(() => { - if (!blockId) return; - switch (type) { + if(!blockId) return; + switch(type) { case BlockType.FileBlock: return ; + case BlockType.VideoBlock: + return ; default: return null; } @@ -70,7 +76,7 @@ function BlockPopover () { const paperRef = useRef(null); useEffect(() => { - if (blockId) { + if(blockId) { setSelectedBlockIds?.([blockId]); } else { @@ -79,18 +85,18 @@ function BlockPopover () { }, [blockId, setSelectedBlockIds]); useEffect(() => { - if (!open) return; + if(!open) return; editor.deselect(); }, [open, editor]); useEffect(() => { const panelPosition = anchorEl?.getBoundingClientRect(); - if (open && panelPosition) { + if(open && panelPosition) { const origins = calculateOptimalOrigins({ top: panelPosition.bottom, left: panelPosition.left, - }, 560, type === BlockType.ImageBlock ? 400 : 200, defaultOrigins, 16); + }, 560, (type === BlockType.ImageBlock || type === BlockType.VideoBlock) ? 400 : 200, defaultOrigins, 16); setOrigins({ transformOrigin: { diff --git a/src/components/editor/components/blocks/image/ImageResizer.tsx b/src/components/editor/components/blocks/image/ImageResizer.tsx index e0d272ac..3760354e 100644 --- a/src/components/editor/components/blocks/image/ImageResizer.tsx +++ b/src/components/editor/components/blocks/image/ImageResizer.tsx @@ -5,11 +5,15 @@ function ImageResizer({ width, onWidthChange, isLeft, + onDragStart, + onDragEnd, }: { isLeft?: boolean; minWidth: number; width: number; onWidthChange: (newWidth: number) => void; + onDragStart?: () => void; + onDragEnd?: () => void; }) { const originalWidth = useRef(width); const startX = useRef(0); @@ -20,19 +24,20 @@ function ImageResizer({ const diff = isLeft ? startX.current - e.clientX : e.clientX - startX.current; const newWidth = originalWidth.current + diff; - if (newWidth < minWidth) { + if(newWidth < minWidth) { return; } onWidthChange(newWidth); }, - [isLeft, minWidth, onWidthChange] + [isLeft, minWidth, onWidthChange], ); const onResizeEnd = useCallback(() => { document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', onResizeEnd); - }, [onResize]); + onDragEnd?.(); + }, [onResize, onDragEnd]); const onResizeStart = useCallback( (e: React.MouseEvent) => { @@ -40,8 +45,9 @@ function ImageResizer({ originalWidth.current = width; document.addEventListener('mousemove', onResize); document.addEventListener('mouseup', onResizeEnd); + onDragStart?.(); }, - [onResize, onResizeEnd, width] + [onResize, onResizeEnd, width, onDragStart], ); return ( diff --git a/src/components/editor/components/blocks/video/VideoBlock.tsx b/src/components/editor/components/blocks/video/VideoBlock.tsx new file mode 100644 index 00000000..a054b40c --- /dev/null +++ b/src/components/editor/components/blocks/video/VideoBlock.tsx @@ -0,0 +1,85 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { AlignType, BlockType } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; +import VideoEmpty from '@/components/editor/components/blocks/video/VideoEmpty'; +import { EditorElementProps, VideoBlockNode } from '@/components/editor/editor.type'; +import React, { forwardRef, memo, useCallback, useMemo, useRef, lazy } from 'react'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +const VideoRender = lazy(() => import('@/components/editor/components/blocks/video/VideoRender')); + +export const VideoBlock = memo(forwardRef>(({ + node, + children, + ...attributes +}, ref) => { + const containerRef = useRef(null); + + const { blockId, data } = node; + const { url, align } = data || {}; + const editor = useSlateStatic() as YjsEditor; + + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + + const className = useMemo(() => { + const classList = ['w-full']; + + if(attributes.className) { + classList.push(attributes.className); + } + + return classList.join(' '); + }, [attributes.className]); + + const alignCss = useMemo(() => { + if(!align) return ''; + + return align === AlignType.Center ? 'justify-center' : align === AlignType.Right ? 'justify-end' : 'justify-start'; + }, [align]); + const { + openPopover, + } = usePopoverContext(); + + const handleClick = useCallback(async() => { + try { + if(!url) { + if(!readOnly && containerRef.current) { + openPopover(blockId, BlockType.VideoBlock, containerRef.current); + } + + return; + } + + // eslint-disable-next-line + } catch(e: any) { + notify.error(e.message); + } + }, [url, readOnly, openPopover, blockId]); + + return ( +
+
+ {url ? : } +
+
+ {children} +
+
+ ); +})); + +export default VideoBlock; + diff --git a/src/components/editor/components/blocks/video/VideoEmpty.tsx b/src/components/editor/components/blocks/video/VideoEmpty.tsx new file mode 100644 index 00000000..73d0b601 --- /dev/null +++ b/src/components/editor/components/blocks/video/VideoEmpty.tsx @@ -0,0 +1,29 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { VideoBlockNode } from '@/components/editor/editor.type'; +import React from 'react'; +import { ReactComponent as ImageIcon } from '@/assets/image.svg'; +import { useTranslation } from 'react-i18next'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +function VideoEmpty({ node }: { node: VideoBlockNode }) { + const { t } = useTranslation(); + const editor = useSlateStatic() as YjsEditor; + + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + + return ( + <> +
+ + {t('embedAVideo')} +
+ + ); +} + +export default VideoEmpty; diff --git a/src/components/editor/components/blocks/video/VideoRender.tsx b/src/components/editor/components/blocks/video/VideoRender.tsx new file mode 100644 index 00000000..55151c6b --- /dev/null +++ b/src/components/editor/components/blocks/video/VideoRender.tsx @@ -0,0 +1,100 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import ImageResizer from '@/components/editor/components/blocks/image/ImageResizer'; +import { MIN_WIDTH } from '@/components/editor/components/blocks/simple-table/const'; +import VideoToolbar from '@/components/editor/components/blocks/video/VideoToolbar'; +import { VideoBlockNode } from '@/components/editor/editor.type'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; +import ReactPlayer from 'react-player'; + +function VideoRender({ + node, +}: { + node: VideoBlockNode +}) { + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const { width: imageWidth } = useMemo(() => node.data || {}, [node.data]); + const url = node.data.url; + const ref = useRef(null); + const handleWidthChange = useCallback( + (newWidth: number) => { + CustomEditor.setBlockData(editor, node.blockId, { + width: newWidth, + }); + }, + [editor, node.blockId], + ); + + const playerProps = useMemo(() => { + return { + url, + width: '100%', + height: '100%', + }; + }, [url]); + const onDragStart = useCallback(() => { + if(!ref.current) return; + ref.current.style.pointerEvents = 'none'; + }, []); + + const onDragEnd = useCallback(() => { + if(!ref.current) return; + ref.current.style.pointerEvents = 'auto'; + }, []); + const [showToolbar, setShowToolbar] = useState(false); + + if(!url) return null; + + return ( +
setShowToolbar(true)} + onMouseLeave={() => setShowToolbar(false)} + className={`image-render w-full h-full relative min-h-[100px]`} + > +
+
+ +
+
+ + + {!readOnly ? ( + <> + + + + ) : null} + {showToolbar && } +
+ ); +} + +export default VideoRender; \ No newline at end of file diff --git a/src/components/editor/components/blocks/video/VideoToolbar.tsx b/src/components/editor/components/blocks/video/VideoToolbar.tsx new file mode 100644 index 00000000..9ca2d807 --- /dev/null +++ b/src/components/editor/components/blocks/video/VideoToolbar.tsx @@ -0,0 +1,64 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { ReactComponent as CopyIcon } from '@/assets/copy.svg'; +import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; +import { notify } from '@/components/_shared/notify'; +import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton'; +import Align from '@/components/editor/components/toolbar/selection-toolbar/actions/Align'; +import { VideoBlockNode } from '@/components/editor/editor.type'; +import { copyTextToClipboard } from '@/utils/copy'; +import { Divider } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +function VideoToolbar({ node }: { + node: VideoBlockNode +}) { + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const { t } = useTranslation(); + const onCopy = async() => { + await copyTextToClipboard(node.data.url || ''); + notify.success(t('copiedVideoLink')); + }; + + const onDelete = () => { + CustomEditor.deleteBlock(editor, node.blockId); + }; + + return ( +
+
+ + + + + + {!readOnly && <> + + + + + } + +
+ +
+ ); +} + +export default VideoToolbar; \ No newline at end of file diff --git a/src/components/editor/components/blocks/video/index.ts b/src/components/editor/components/blocks/video/index.ts new file mode 100644 index 00000000..03b756c7 --- /dev/null +++ b/src/components/editor/components/blocks/video/index.ts @@ -0,0 +1 @@ +export * from './VideoBlock'; \ No newline at end of file diff --git a/src/components/editor/components/element/Element.tsx b/src/components/editor/components/element/Element.tsx index f831eb87..013d6da8 100644 --- a/src/components/editor/components/element/Element.tsx +++ b/src/components/editor/components/element/Element.tsx @@ -20,6 +20,7 @@ import SimpleTableCell from '@/components/editor/components/blocks/simple-table/ import SimpleTableRow from '@/components/editor/components/blocks/simple-table/SimpleTableRow'; import { TableBlock, TableCellBlock } from '@/components/editor/components/blocks/table'; import { Text } from '@/components/editor/components/blocks/text'; +import { VideoBlock } from '@/components/editor/components/blocks/video'; import { useEditorContext } from '@/components/editor/EditorContext'; import { ElementFallbackRender } from '@/components/error/ElementFallbackRender'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; @@ -50,8 +51,8 @@ export const Element = ({ const { blockId, type } = node; const isSelected = useSelected(); const selected = useMemo(() => { - if (blockId && selectedBlockIds?.includes(blockId)) return true; - if ([ + if(blockId && selectedBlockIds?.includes(blockId)) return true; + if([ ...CONTAINER_BLOCK_TYPES, ...SOFT_BREAK_TYPES, BlockType.HeadingBlock, @@ -66,15 +67,15 @@ export const Element = ({ const highlightTimeoutRef = React.useRef(); useEffect(() => { - if (!jumpBlockId) return; + if(!jumpBlockId) return; - if (node.blockId !== jumpBlockId) { + if(node.blockId !== jumpBlockId) { return; } const element = ReactEditor.toDOMNode(editor, node); - void (async () => { + void (async() => { await smoothScrollIntoViewIfNeeded(element, { behavior: 'smooth', scrollMode: 'if-needed', @@ -91,13 +92,13 @@ export const Element = ({ useEffect(() => { return () => { - if (highlightTimeoutRef.current) { + if(highlightTimeoutRef.current) { clearTimeout(highlightTimeoutRef.current); } }; }, []); const Component = useMemo(() => { - switch (type) { + switch(type) { case BlockType.HeadingBlock: return Heading; case BlockType.TodoListBlock: @@ -148,6 +149,8 @@ export const Element = ({ return SimpleTableRow; case BlockType.SimpleTableCellBlock: return SimpleTableCell; + case BlockType.VideoBlock: + return VideoBlock; default: return UnSupportedBlock; } @@ -158,11 +161,11 @@ export const Element = ({ const align = data.align; const classList = ['block-element relative flex rounded-[4px]']; - if (selected) { + if(selected) { classList.push('selected'); } - if (align) { + if(align) { classList.push(`block-align-${align}`); } @@ -181,12 +184,12 @@ export const Element = ({ const fallbackRender = useMemo(() => { return (props: FallbackProps) => { return ( - + ); }; }, [node]); - if (type === YjsEditorKey.text) { + if(type === YjsEditorKey.text) { return ( {children} @@ -194,7 +197,7 @@ export const Element = ({ ); } - if ([BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock].includes(node.type as BlockType)) { + if([BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock].includes(node.type as BlockType)) { return ( (({ node }, ref) => { +export const UnSupportedBlock = forwardRef(({ node, children }, ref) => { return (
((
{JSON.stringify(node, null, 2)}
- + {children}
); diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index be365ab2..adf0f1f9 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -10,7 +10,7 @@ import { HeadingBlockData, ImageBlockData, SubpageNodeData, - ToggleListBlockData, + ToggleListBlockData, VideoBlockData, ViewLayout, } from '@/application/types'; import { ReactComponent as AddDocumentIcon } from '@/assets/slash_menu_icon_add_doc.svg'; @@ -40,6 +40,8 @@ import { ReactComponent as ToggleHeading1Icon } from '@/assets/toggle_heading1.s import { ReactComponent as ToggleHeading2Icon } from '@/assets/toggle_heading2.svg'; import { ReactComponent as ToggleHeading3Icon } from '@/assets/toggle_heading3.svg'; import { ReactComponent as MathIcon } from '@/assets/slash_menu_icon_math_equation.svg'; +import { ReactComponent as VideoIcon } from '@/assets/video.svg'; + import { notify } from '@/components/_shared/notify'; import { calculateOptimalOrigins, Popover } from '@/components/_shared/popover'; import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; @@ -53,7 +55,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactEditor, useSlateStatic } from 'slate-react'; -export function SlashPanel ({ +export function SlashPanel({ setEmojiPosition, }: { setEmojiPosition: (position: { top: number; left: number }) => void; @@ -91,24 +93,24 @@ export function SlashPanel ({ const isEmpty = !CustomEditor.getBlockTextContent(block[0], 2); let newBlockId: string | undefined; - if (isEmpty) { + if(isEmpty) { newBlockId = CustomEditor.turnToBlock(editor, blockId, type, data); } else { newBlockId = CustomEditor.addBelowBlock(editor, blockId, type, data); } - if (newBlockId && isEmbedBlockTypes(type)) { + if(newBlockId && isEmbedBlockTypes(type)) { const [, path] = findSlateEntryByBlockId(editor, newBlockId); editor.select(editor.start(path)); } - if ([BlockType.FileBlock, BlockType.ImageBlock, BlockType.EquationBlock].includes(type)) { + if([BlockType.FileBlock, BlockType.ImageBlock, BlockType.EquationBlock, BlockType.VideoBlock].includes(type)) { setTimeout(() => { - if (!newBlockId) return; + if(!newBlockId) return; const entry = findSlateEntryByBlockId(editor, newBlockId); - if (!entry) return; + if(!entry) return; const [node] = entry; const dom = ReactEditor.toDOMNode(editor, node); @@ -190,6 +192,17 @@ export function SlashPanel ({ align: AlignType.Center, } as ImageBlockData); }, + }, { + label: t('embedVideo'), + key: 'video', + icon: , + keywords: ['video', 'youtube', 'embed'], + onClick: () => { + turnInto(BlockType.VideoBlock, { + url: '', + align: AlignType.Center, + } as VideoBlockData); + }, }, { label: t('document.slashMenu.name.bulletedList'), key: 'bulletedList', @@ -238,7 +251,7 @@ export function SlashPanel ({ onClick: () => { const rect = getRangeRect(); - if (!rect) return; + if(!rect) return; openPanel(PanelType.PageReference, { top: rect.top, left: rect.left }); }, }, { @@ -246,8 +259,8 @@ export function SlashPanel ({ key: 'document', icon: , keywords: ['document', 'doc', 'page', 'create', 'add'], - onClick: async () => { - if (!viewId || !addPage || !openPageModal) return; + onClick: async() => { + if(!viewId || !addPage || !openPageModal) return; try { const newViewId = await addPage(viewId, { layout: ViewLayout.Document, @@ -259,7 +272,7 @@ export function SlashPanel ({ openPageModal(newViewId); // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }, @@ -435,7 +448,7 @@ export function SlashPanel ({ setTimeout(() => { const rect = getRangeRect(); - if (!rect) return; + if(!rect) return; setEmojiPosition({ top: rect.top, left: rect.left, @@ -452,7 +465,7 @@ export function SlashPanel ({ turnInto(BlockType.FileBlock, {}); }, }].filter((option) => { - if (!searchText) return true; + if(!searchText) return true; return option.keywords.some((keyword: string) => { return keyword.toLowerCase().includes(searchText.toLowerCase()); }); @@ -463,7 +476,7 @@ export function SlashPanel ({ useEffect(() => { selectedOptionRef.current = selectedOption; - if (!selectedOption) return; + if(!selectedOption) return; const el = optionsRef.current?.querySelector(`[data-option-key="${selectedOption}"]`) as HTMLButtonElement | null; el?.scrollIntoView({ @@ -473,22 +486,22 @@ export function SlashPanel ({ }, [selectedOption]); useEffect(() => { - if (!open || options.length === 0) return; + if(!open || options.length === 0) return; setSelectedOption(options[0].key); }, [open, options]); const countRef = useRef(0); useEffect(() => { - if (!open) return; + if(!open) return; - if (searchText && resultLength === 0) { + if(searchText && resultLength === 0) { countRef.current += 1; } else { countRef.current = 0; } - if (countRef.current > 1) { + if(countRef.current > 1) { closePanel(); countRef.current = 0; return; @@ -498,14 +511,14 @@ export function SlashPanel ({ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!open) return; + if(!open) return; const { key } = e; - switch (key) { + switch(key) { case 'Enter': e.stopPropagation(); e.preventDefault(); - if (selectedOptionRef.current) { + if(selectedOptionRef.current) { handleSelectOption(selectedOptionRef.current); const item = options.find((option) => option.key === selectedOptionRef.current); @@ -540,12 +553,12 @@ export function SlashPanel ({ }, [closePanel, editor, open, options, handleSelectOption]); useEffect(() => { - if (options.length > 0) return; + if(options.length > 0) return; setSelectedOption(null); }, [options.length]); useEffect(() => { - if (open && panelPosition) { + if(open && panelPosition) { const origins = calculateOptimalOrigins(panelPosition, 320, 400, undefined, 16); const isAlignBottom = origins.transformOrigin.vertical === 'bottom'; diff --git a/src/components/editor/editor.type.ts b/src/components/editor/editor.type.ts index 278e310d..f03cd5e9 100644 --- a/src/components/editor/editor.type.ts +++ b/src/components/editor/editor.type.ts @@ -15,7 +15,7 @@ import { BlockId, BlockData, DatabaseNodeData, - LinkPreviewBlockData, FileBlockData, GalleryBlockData, SubpageNodeData, SimpleTableData, + LinkPreviewBlockData, FileBlockData, GalleryBlockData, SubpageNodeData, SimpleTableData, VideoBlockData, } from '@/application/types'; import { HTMLAttributes } from 'react'; import { Element } from 'slate'; @@ -114,6 +114,12 @@ export interface ImageBlockNode extends BlockNode { data: ImageBlockData; } +export interface VideoBlockNode extends BlockNode { + type: BlockType.VideoBlock; + blockId: string; + data: VideoBlockData; +} + export interface GalleryBlockNode extends BlockNode { type: BlockType.GalleryBlock; blockId: string; diff --git a/src/components/editor/plugins/withPasted.ts b/src/components/editor/plugins/withPasted.ts index d069b578..8165a45e 100644 --- a/src/components/editor/plugins/withPasted.ts +++ b/src/components/editor/plugins/withPasted.ts @@ -6,7 +6,7 @@ import { getBlockEntry, getSharedRoot, } from '@/application/slate-yjs/utils/editor'; -import { BlockType, LinkPreviewBlockData, MentionType, YjsEditorKey } from '@/application/types'; +import { BlockType, LinkPreviewBlockData, MentionType, VideoBlockData, YjsEditorKey } from '@/application/types'; import { deserializeHTML } from '@/components/editor/utils/fragment'; import { BasePoint, Node, Transforms, Text, Element } from 'slate'; import { ReactEditor } from 'slate-react'; @@ -18,11 +18,11 @@ import { processUrl } from '@/utils/url'; export const withPasted = (editor: ReactEditor) => { editor.insertTextData = (data: DataTransfer) => { - if (!beforePasted(editor)) + if(!beforePasted(editor)) return false; const text = data.getData('text/plain'); - if (text) { + if(text) { const lines = text.split(/\r\n|\r|\n/); @@ -32,19 +32,19 @@ export const withPasted = (editor: ReactEditor) => { const point = editor.selection?.anchor as BasePoint; const [node] = getBlockEntry(editor as YjsEditor, point); - if (lineLength === 1) { + if(lineLength === 1) { const isUrl = !!processUrl(text); - if (isUrl) { + if(isUrl) { const isAppFlowyLinkUrl = isURL(text, { host_whitelist: [window.location.hostname], }); - if (isAppFlowyLinkUrl) { + if(isAppFlowyLinkUrl) { const url = new URL(text); const blockId = url.searchParams.get('blockId'); - if (blockId) { + if(blockId) { const pageId = url.pathname.split('/').pop(); const point = editor.selection?.anchor as BasePoint; @@ -60,9 +60,19 @@ export const withPasted = (editor: ReactEditor) => { } } - // const currentBlockId = node.blockId as string; - // - // CustomEditor.addBelowBlock(editor as YjsEditor, currentBlockId, BlockType.LinkPreview, { url: text } as LinkPreviewBlockData); + const isVideoUrl = isURL(text, { + host_whitelist: ['youtube.com', 'www.youtube.com', 'youtu.be', 'vimeo.com'], + }); + + if(isVideoUrl) { + insertFragment(editor, [{ + type: BlockType.VideoBlock, + data: { url: text } as VideoBlockData, + children: [{ text: '' }], + }]); + return true; + } + insertFragment(editor, [{ type: BlockType.LinkPreview, data: { url: text } as LinkPreviewBlockData, @@ -73,8 +83,8 @@ export const withPasted = (editor: ReactEditor) => { } } - if (lineLength > 1 && node.type !== BlockType.CodeBlock) { - if (html) { + if(lineLength > 1 && node.type !== BlockType.CodeBlock) { + if(html) { return insertHtmlData(editor, data); } else { const fragment = lines.map((line) => ({ type: BlockType.Paragraph, children: [{ text: line }] })); @@ -84,10 +94,10 @@ export const withPasted = (editor: ReactEditor) => { } } - for (const line of lines) { + for(const line of lines) { const point = editor.selection?.anchor as BasePoint; - if (line) { + if(line) { Transforms.insertNodes(editor, { text: `${line}${lineLength > 1 ? `\n` : ''}` }, { at: point, select: true, @@ -109,10 +119,10 @@ export const withPasted = (editor: ReactEditor) => { return editor; }; -export function insertHtmlData (editor: ReactEditor, data: DataTransfer) { +export function insertHtmlData(editor: ReactEditor, data: DataTransfer) { const html = data.getData('text/html'); - if (html) { + if(html) { console.log('insert HTML Data', html); const fragment = deserializeHTML(html) as Node[]; @@ -124,9 +134,9 @@ export function insertHtmlData (editor: ReactEditor, data: DataTransfer) { return false; } -function insertFragment (editor: ReactEditor, fragment: Node[], options = {}) { +function insertFragment(editor: ReactEditor, fragment: Node[], options = {}) { console.log('insertFragment', fragment, options); - if (!beforePasted(editor)) + if(!beforePasted(editor)) return; const point = editor.selection?.anchor as BasePoint; @@ -140,15 +150,15 @@ function insertFragment (editor: ReactEditor, fragment: Node[], options = {}) { const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); const doc = assertDocExists(sharedRoot); - if (fragment.length === 1) { + if(fragment.length === 1) { const firstNode = fragment[0] as Element; const findTextNodes = (node: Node): Node[] => { - if (Text.isText(node)) { + if(Text.isText(node)) { return []; } - if (Element.isElement(node) && node.textId) { + if(Element.isElement(node) && node.textId) { return [node]; } @@ -157,7 +167,7 @@ function insertFragment (editor: ReactEditor, fragment: Node[], options = {}) { const textNodes = findTextNodes(firstNode); - if (textNodes.length === 1) { + if(textNodes.length === 1) { const textNode = textNodes[0] as Element; const texts = textNode.children.filter((node) => Text.isText(node)); @@ -173,7 +183,7 @@ function insertFragment (editor: ReactEditor, fragment: Node[], options = {}) { const newBlockIds = slateContentInsertToYData(block.get(YjsEditorKey.block_parent), index + 1, fragment, doc); lastBlockId = newBlockIds[newBlockIds.length - 1]; - if (isEmptyNode) { + if(isEmptyNode) { deleteBlock(sharedRoot, blockId); } });