mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2026-03-13 10:00:26 +08:00
feat: support embed video on document (#47)
This commit is contained in:
@@ -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
28
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
85
src/components/editor/components/blocks/video/VideoBlock.tsx
Normal file
85
src/components/editor/components/blocks/video/VideoBlock.tsx
Normal 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;
|
||||
|
||||
29
src/components/editor/components/blocks/video/VideoEmpty.tsx
Normal file
29
src/components/editor/components/blocks/video/VideoEmpty.tsx
Normal 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;
|
||||
100
src/components/editor/components/blocks/video/VideoRender.tsx
Normal file
100
src/components/editor/components/blocks/video/VideoRender.tsx
Normal 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;
|
||||
@@ -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;
|
||||
1
src/components/editor/components/blocks/video/index.ts
Normal file
1
src/components/editor/components/blocks/video/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './VideoBlock';
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user