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);
}
});