feat: support embed video on document (#47)

This commit is contained in:
Kilu.He
2025-02-26 17:19:23 +08:00
committed by GitHub
parent 67d1fca11d
commit ae2444c190
17 changed files with 537 additions and 78 deletions

View File

@@ -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",

28
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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: <div className={'flex flex-col my-2'}><EmbedLink
onDone={handleInsertEmbedLink}
defaultLink={(entry?.[0].data as VideoBlockData).url}
placeholder={t('embedVideoLinkPlaceholder')}
/>
<div className={'text-text-caption text-sm w-full text-center'}>{t('videoSupported')}</div>
</div>,
},
];
}, [entry, handleInsertEmbedLink, t]);
const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue);
return (
<div className={'flex flex-col p-2 gap-2'}>
<ViewTabs
value={tabValue}
onChange={handleTabChange}
className={'min-h-[38px] px-2 border-b border-line-divider w-[560px] max-w-[964px]'}
>
{tabOptions.map((tab) => {
const { key, label } = tab;
return <ViewTab
key={key}
iconPosition="start"
color="inherit"
label={label}
value={key}
/>;
})}
</ViewTabs>
{tabOptions.map((tab, index) => {
const { key, panel } = tab;
return (
<TabPanel
className={'flex h-full w-full flex-col'}
key={key}
index={index}
value={selectedIndex}
>
{panel}
</TabPanel>
);
})}
</div>
);
}
export default VideoBlockPopoverContent;

View File

@@ -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 <FileBlockPopoverContent
blockId={blockId}
@@ -62,6 +63,11 @@ function BlockPopover () {
blockId={blockId}
onClose={handleClose}
/>;
case BlockType.VideoBlock:
return <VideoBlockPopoverContent
blockId={blockId}
onClose={handleClose}
/>;
default:
return null;
}
@@ -70,7 +76,7 @@ function BlockPopover () {
const paperRef = useRef<HTMLDivElement>(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: {

View File

@@ -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 (

View File

@@ -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<HTMLDivElement, EditorElementProps<VideoBlockNode>>(({
node,
children,
...attributes
}, ref) => {
const containerRef = useRef<HTMLDivElement>(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 (
<div
{...attributes}
ref={containerRef}
className={className}
onClick={handleClick}
>
<div
contentEditable={false}
className={`embed-block relative ${alignCss} ${url ? '!bg-transparent !border-none !rounded-none' : 'p-4'}`}
>
{url ? <VideoRender node={node} /> : <VideoEmpty node={node} />}
</div>
<div
ref={ref}
className={'absolute left-0 top-0 h-full w-full select-none caret-transparent'}
>
{children}
</div>
</div>
);
}));
export default VideoBlock;

View File

@@ -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 (
<>
<div
className={
`flex w-full select-none items-center gap-4 text-text-caption ${readOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`
}
>
<ImageIcon className={'w-6 h-6'} />
{t('embedAVideo')}
</div>
</>
);
}
export default VideoEmpty;

View File

@@ -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<HTMLDivElement>(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 (
<div
style={{
width: node.data.width ? `${node.data.width}px` : '100%',
}}
onMouseEnter={() => setShowToolbar(true)}
onMouseLeave={() => setShowToolbar(false)}
className={`image-render w-full h-full relative min-h-[100px]`}
>
<div
style={{
paddingBottom: '56.25%',
height: '0px',
width: '100%',
}}
>
<div
ref={ref}
className={'w-full absolute left-0 top-0 h-full'}
>
<ReactPlayer {...playerProps} />
</div>
</div>
{!readOnly ? (
<>
<ImageResizer
isLeft
minWidth={MIN_WIDTH}
width={imageWidth || ref.current?.offsetWidth || 0}
onWidthChange={handleWidthChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
<ImageResizer
minWidth={MIN_WIDTH}
width={imageWidth || ref.current?.offsetWidth || 0}
onWidthChange={handleWidthChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
</>
) : null}
{showToolbar && <VideoToolbar node={node} />}
</div>
);
}
export default VideoRender;

View File

@@ -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 (
<div className={'absolute z-10 top-0 right-0'}>
<div className={'flex space-x-1 rounded-[8px] p-1 bg-fill-toolbar shadow border border-line-divider '}>
<ActionButton
onClick={onCopy}
tooltip={t('button.copyLinkOriginal')}
>
<CopyIcon />
</ActionButton>
{!readOnly && <>
<Align
blockId={node.blockId}
/>
<Divider
className={'my-1.5 bg-line-on-toolbar'}
orientation={'vertical'}
flexItem={true}
/>
<ActionButton
onClick={onDelete}
tooltip={t('button.delete')}
>
<DeleteIcon />
</ActionButton></>}
</div>
</div>
);
}
export default VideoToolbar;

View File

@@ -0,0 +1 @@
export * from './VideoBlock';

View File

@@ -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<NodeJS.Timeout>();
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 (
<ElementFallbackRender {...props} description={JSON.stringify(node)}/>
<ElementFallbackRender {...props} description={JSON.stringify(node)} />
);
};
}, [node]);
if (type === YjsEditorKey.text) {
if(type === YjsEditorKey.text) {
return (
<Text {...attributes} node={node as TextNode}>
{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 (
<Component
node={node}

View File

@@ -2,7 +2,7 @@ import { EditorElementProps } from '@/components/editor/editor.type';
import React, { forwardRef } from 'react';
import { Alert } from '@mui/material';
export const UnSupportedBlock = forwardRef<HTMLDivElement, EditorElementProps>(({ node }, ref) => {
export const UnSupportedBlock = forwardRef<HTMLDivElement, EditorElementProps>(({ node, children }, ref) => {
return (
<div
className={'w-full'}
@@ -25,7 +25,7 @@ export const UnSupportedBlock = forwardRef<HTMLDivElement, EditorElementProps>((
<span className={'text-sm'}>
<pre><code>{JSON.stringify(node, null, 2)}</code></pre>
</span>
{children}
</Alert>
</div>
);

View File

@@ -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: <VideoIcon />,
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: <AddDocumentIcon />,
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';

View File

@@ -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;

View File

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