@os-design/editor 1.0.204 → 1.0.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -8
- package/src/@types/emotion.d.ts +7 -0
- package/src/Editor/BlockToolbar.tsx +49 -0
- package/src/Editor/StyleToolbar.tsx +80 -0
- package/src/Editor/Toolbar.tsx +18 -0
- package/src/Editor/ToolbarButton.tsx +65 -0
- package/src/Editor/blocks/Figure.tsx +10 -0
- package/src/Editor/blocks/FigureCaption.tsx +19 -0
- package/src/Editor/blocks/imageBlock.tsx +162 -0
- package/src/Editor/blocks/types.ts +21 -0
- package/src/Editor/blocks/videoBlock.tsx +83 -0
- package/src/Editor/decorators/linkDecorator.tsx +23 -0
- package/src/Editor/hooks/useBlockToolbarProps.ts +72 -0
- package/src/Editor/hooks/usePastedTextHandler.ts +47 -0
- package/src/Editor/hooks/useReturnHandler.ts +37 -0
- package/src/Editor/hooks/useStyleToolbarProps.ts +53 -0
- package/src/Editor/index.tsx +203 -0
- package/src/Editor/styles/defaultDraftJsStyles.ts +179 -0
- package/src/Editor/styles/overrideDraftJsStyles.ts +20 -0
- package/src/Editor/utils/addNewBlockAt.ts +59 -0
- package/src/Editor/utils/changeBlock.ts +20 -0
- package/src/Editor/utils/createContentEditorState.ts +7 -0
- package/src/Editor/utils/createDecorator.ts +7 -0
- package/src/Editor/utils/createEmptyEditorState.ts +7 -0
- package/src/Editor/utils/defaultStyleToolbarItems.tsx +30 -0
- package/src/Editor/utils/getCurrentBlock.ts +9 -0
- package/src/Editor/utils/getSelectedBlockElement.ts +17 -0
- package/src/Editor/utils/getSelectionRange.ts +7 -0
- package/src/Editor/utils/setLink.ts +22 -0
- package/src/Editor/utils/transformers.ts +20 -0
- package/src/Editor/utils/unsetLink.ts +11 -0
- package/src/EditorSkeleton/index.tsx +21 -0
- package/src/index.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@os-design/editor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.206",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"repository": "git@gitlab.com:os-team/libs/os-design.git",
|
|
6
6
|
"main": "dist/cjs/index.js",
|
|
@@ -14,7 +14,14 @@
|
|
|
14
14
|
"./package.json": "./package.json"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"!**/*.test.ts",
|
|
20
|
+
"!**/*.test.tsx",
|
|
21
|
+
"!**/__tests__",
|
|
22
|
+
"!**/*.stories.tsx",
|
|
23
|
+
"!**/*.stories.mdx",
|
|
24
|
+
"!**/*.example.tsx"
|
|
18
25
|
],
|
|
19
26
|
"sideEffects": false,
|
|
20
27
|
"scripts": {
|
|
@@ -29,11 +36,11 @@
|
|
|
29
36
|
"access": "public"
|
|
30
37
|
},
|
|
31
38
|
"dependencies": {
|
|
32
|
-
"@os-design/core": "^1.0.
|
|
33
|
-
"@os-design/icons": "^1.0.
|
|
34
|
-
"@os-design/styles": "^1.0.
|
|
35
|
-
"@os-design/theming": "^1.0.
|
|
36
|
-
"@os-design/utils": "^1.0.
|
|
39
|
+
"@os-design/core": "^1.0.201",
|
|
40
|
+
"@os-design/icons": "^1.0.49",
|
|
41
|
+
"@os-design/styles": "^1.0.46",
|
|
42
|
+
"@os-design/theming": "^1.0.44",
|
|
43
|
+
"@os-design/utils": "^1.0.63",
|
|
37
44
|
"draft-js": "^0.11.7"
|
|
38
45
|
},
|
|
39
46
|
"devDependencies": {
|
|
@@ -49,5 +56,5 @@
|
|
|
49
56
|
"react": ">=18",
|
|
50
57
|
"react-dom": ">=18"
|
|
51
58
|
},
|
|
52
|
-
"gitHead": "
|
|
59
|
+
"gitHead": "e5d8409760608145d2c738aa5789d0465ae5416f"
|
|
53
60
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PopoverProps } from '@os-design/core';
|
|
2
|
+
import { EditorState } from 'draft-js';
|
|
3
|
+
import React, { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import Toolbar from './Toolbar';
|
|
6
|
+
import ToolbarButton from './ToolbarButton';
|
|
7
|
+
import { BlockToolbarItem } from './blocks/types';
|
|
8
|
+
import getCurrentBlock from './utils/getCurrentBlock';
|
|
9
|
+
|
|
10
|
+
interface BlockToolbarProps extends Omit<PopoverProps, 'onChange'> {
|
|
11
|
+
items: BlockToolbarItem[];
|
|
12
|
+
value: EditorState;
|
|
13
|
+
onChange: (value: EditorState) => void;
|
|
14
|
+
setReadOnly: (readOnly: boolean) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BlockToolbar: React.FC<BlockToolbarProps> = ({
|
|
18
|
+
items,
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
setReadOnly,
|
|
22
|
+
...rest
|
|
23
|
+
}) => {
|
|
24
|
+
const clickHandler = useCallback<(item: BlockToolbarItem) => void>(
|
|
25
|
+
({ onClick }) => {
|
|
26
|
+
const currentBlock = getCurrentBlock(value);
|
|
27
|
+
if (currentBlock.getType() !== 'unstyled' || currentBlock.getLength() > 0)
|
|
28
|
+
return;
|
|
29
|
+
onClick({
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
setReadOnly,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
[value, onChange, setReadOnly]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Toolbar {...rest}>
|
|
40
|
+
{items.map((item) => (
|
|
41
|
+
<ToolbarButton key={item.type} onClick={() => clickHandler(item)}>
|
|
42
|
+
{item.icon}
|
|
43
|
+
</ToolbarButton>
|
|
44
|
+
))}
|
|
45
|
+
</Toolbar>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default BlockToolbar;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { PopoverProps } from '@os-design/core';
|
|
2
|
+
import { EditorState, RichUtils } from 'draft-js';
|
|
3
|
+
|
|
4
|
+
import React, { useCallback, useMemo } from 'react';
|
|
5
|
+
import Toolbar from './Toolbar';
|
|
6
|
+
import ToolbarButton from './ToolbarButton';
|
|
7
|
+
|
|
8
|
+
import { StyleToolbarItem } from './utils/defaultStyleToolbarItems';
|
|
9
|
+
import setLink from './utils/setLink';
|
|
10
|
+
import unsetLink from './utils/unsetLink';
|
|
11
|
+
|
|
12
|
+
interface StyleToolbarProps extends Omit<PopoverProps, 'onChange'> {
|
|
13
|
+
items: StyleToolbarItem[];
|
|
14
|
+
value: EditorState;
|
|
15
|
+
onChange: (value: EditorState) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const StyleToolbar: React.FC<StyleToolbarProps> = ({
|
|
19
|
+
items,
|
|
20
|
+
value,
|
|
21
|
+
onChange = () => {},
|
|
22
|
+
...rest
|
|
23
|
+
}) => {
|
|
24
|
+
const currentBlockType = useMemo(
|
|
25
|
+
() => RichUtils.getCurrentBlockType(value),
|
|
26
|
+
[value]
|
|
27
|
+
);
|
|
28
|
+
const currentBlockContainsLink = useMemo(
|
|
29
|
+
() => RichUtils.currentBlockContainsLink(value),
|
|
30
|
+
[value]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const toggleLink = useCallback(() => {
|
|
34
|
+
if (currentBlockContainsLink) {
|
|
35
|
+
onChange(unsetLink(value));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// eslint-disable-next-line no-alert
|
|
39
|
+
const url = prompt('Paste or type a link');
|
|
40
|
+
if (!url) return;
|
|
41
|
+
onChange(setLink(value, url));
|
|
42
|
+
}, [currentBlockContainsLink, value, onChange]);
|
|
43
|
+
|
|
44
|
+
const clickHandler = useCallback<(item: StyleToolbarItem) => void>(
|
|
45
|
+
(item) => {
|
|
46
|
+
if (item.type === 'inline') {
|
|
47
|
+
onChange(RichUtils.toggleInlineStyle(value, item.name));
|
|
48
|
+
} else if (item.type === 'block') {
|
|
49
|
+
onChange(RichUtils.toggleBlockType(value, item.name));
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[value, onChange]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (items.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Toolbar {...rest}>
|
|
59
|
+
{items.map((item) => {
|
|
60
|
+
const isLink = item.name === 'LINK' && item.type === 'inline';
|
|
61
|
+
|
|
62
|
+
const active = isLink
|
|
63
|
+
? currentBlockContainsLink
|
|
64
|
+
: value.getCurrentInlineStyle().has(item.name) ||
|
|
65
|
+
currentBlockType === item.name;
|
|
66
|
+
const onClick = isLink ? toggleLink : () => clickHandler(item);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<ToolbarButton key={item.name} active={active} onClick={onClick}>
|
|
70
|
+
{item.icon}
|
|
71
|
+
</ToolbarButton>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
</Toolbar>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
StyleToolbar.displayName = 'StyleToolbar';
|
|
79
|
+
|
|
80
|
+
export default StyleToolbar;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { Popover } from '@os-design/core';
|
|
3
|
+
import { clr } from '@os-design/theming';
|
|
4
|
+
|
|
5
|
+
const Toolbar = styled(Popover)`
|
|
6
|
+
// Reset popover styles
|
|
7
|
+
border: 0;
|
|
8
|
+
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-direction: row;
|
|
11
|
+
overflow: hidden; // For border-radius
|
|
12
|
+
|
|
13
|
+
background-color: ${(p) => clr(p.theme.editorToolbarButtonColorBg)};
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
Toolbar.displayName = 'Toolbar';
|
|
17
|
+
|
|
18
|
+
export default Toolbar;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { css } from '@emotion/react';
|
|
2
|
+
|
|
3
|
+
import styled from '@emotion/styled';
|
|
4
|
+
import { resetButtonStyles, transitionStyles } from '@os-design/styles';
|
|
5
|
+
import { clr } from '@os-design/theming';
|
|
6
|
+
|
|
7
|
+
import { omitEmotionProps } from '@os-design/utils';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
type JsxButtonProps = JSX.IntrinsicElements['button'];
|
|
11
|
+
interface ToolbarButtonProps extends JsxButtonProps {
|
|
12
|
+
active?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const activeStyles = (p) =>
|
|
16
|
+
p.active &&
|
|
17
|
+
css`
|
|
18
|
+
background-color: ${clr(p.theme.editorToolbarButtonColorBgActive)};
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
type StyledToolbarButtonProps = Pick<ToolbarButtonProps, 'active'>;
|
|
22
|
+
const StyledToolbarButton = styled(
|
|
23
|
+
'button',
|
|
24
|
+
omitEmotionProps('active')
|
|
25
|
+
)<StyledToolbarButtonProps>`
|
|
26
|
+
${resetButtonStyles};
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
font-size: 1.3em;
|
|
29
|
+
|
|
30
|
+
display: flex;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
align-items: center;
|
|
33
|
+
|
|
34
|
+
width: ${(p) => p.theme.editorToolbarButtonSize}em;
|
|
35
|
+
height: ${(p) => p.theme.editorToolbarButtonSize}em;
|
|
36
|
+
background-color: ${(p) => clr(p.theme.editorToolbarButtonColorBg)};
|
|
37
|
+
color: ${(p) => clr(p.theme.editorToolbarButtonColorText)};
|
|
38
|
+
|
|
39
|
+
@media (hover: hover) {
|
|
40
|
+
&:hover,
|
|
41
|
+
&:focus {
|
|
42
|
+
background-color: ${(p) => clr(p.theme.editorToolbarButtonColorBgHover)};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
${activeStyles};
|
|
47
|
+
${transitionStyles('background-color')};
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
|
51
|
+
onMouseDown = () => {},
|
|
52
|
+
...rest
|
|
53
|
+
}) => (
|
|
54
|
+
<StyledToolbarButton
|
|
55
|
+
onMouseDown={(e) => {
|
|
56
|
+
onMouseDown(e);
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
}}
|
|
59
|
+
{...rest}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
ToolbarButton.displayName = 'ToolbarButton';
|
|
64
|
+
|
|
65
|
+
export default ToolbarButton;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import styled from '@emotion/styled';
|
|
2
|
+
import { clr } from '@os-design/theming';
|
|
3
|
+
|
|
4
|
+
const FigureCaption = styled.figcaption`
|
|
5
|
+
font-size: ${(p) => p.theme.sizes.small}em;
|
|
6
|
+
padding: 0.4em 0.8em;
|
|
7
|
+
|
|
8
|
+
background-color: ${(p) => clr(p.theme.editorFigureCaptionColorBg)};
|
|
9
|
+
color: ${(p) => clr(p.theme.editorFigureCaptionColorText)};
|
|
10
|
+
|
|
11
|
+
& > div {
|
|
12
|
+
text-align: center !important;
|
|
13
|
+
margin: 0 !important;
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
FigureCaption.displayName = 'FigureCaption';
|
|
18
|
+
|
|
19
|
+
export default FigureCaption;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { css } from '@emotion/react';
|
|
2
|
+
|
|
3
|
+
import styled from '@emotion/styled';
|
|
4
|
+
import { message } from '@os-design/core';
|
|
5
|
+
|
|
6
|
+
import { Loading, Picture } from '@os-design/icons';
|
|
7
|
+
|
|
8
|
+
import { omitEmotionProps, useEvent } from '@os-design/utils';
|
|
9
|
+
import { EditorBlock } from 'draft-js';
|
|
10
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
import changeBlock from '../utils/changeBlock';
|
|
13
|
+
import getCurrentBlock from '../utils/getCurrentBlock';
|
|
14
|
+
import Figure from './Figure';
|
|
15
|
+
import FigureCaption from './FigureCaption';
|
|
16
|
+
|
|
17
|
+
import { BlockProps, BlockToolbarItem } from './types';
|
|
18
|
+
|
|
19
|
+
const widthStyles = (p) =>
|
|
20
|
+
p.width &&
|
|
21
|
+
css`
|
|
22
|
+
width: ${p.width}px;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
interface ImageFigureProps {
|
|
26
|
+
width: number;
|
|
27
|
+
}
|
|
28
|
+
const ImageFigure = styled(Figure, omitEmotionProps('width'))<ImageFigureProps>`
|
|
29
|
+
position: relative;
|
|
30
|
+
display: inline-block;
|
|
31
|
+
max-width: 100%;
|
|
32
|
+
${widthStyles};
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const Mask = styled.div`
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 0;
|
|
38
|
+
right: 0;
|
|
39
|
+
bottom: 0;
|
|
40
|
+
left: 0;
|
|
41
|
+
|
|
42
|
+
display: flex;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
align-items: center;
|
|
45
|
+
|
|
46
|
+
background-color: hsla(
|
|
47
|
+
0,
|
|
48
|
+
0%,
|
|
49
|
+
0%,
|
|
50
|
+
${(p) => p.theme.editorBlockImageMaskOpacity}
|
|
51
|
+
);
|
|
52
|
+
color: hsl(0, 0%, 100%);
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const LoadingIcon = styled(Loading)`
|
|
56
|
+
font-size: ${(p) => p.theme.editorBlockImageLoadingFontSize}em;
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const Image = styled.img`
|
|
60
|
+
max-width: 100%;
|
|
61
|
+
max-height: ${(p) => p.theme.editorBlockImageMaxHeight}em;
|
|
62
|
+
vertical-align: bottom;
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const ImageBlock: React.FC<BlockProps> = (props) => {
|
|
66
|
+
const { block } = props;
|
|
67
|
+
const data = block.getData();
|
|
68
|
+
const src = data.get('src') as string;
|
|
69
|
+
const loading = data.get('loading') as boolean;
|
|
70
|
+
|
|
71
|
+
// Update the width of the image
|
|
72
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
73
|
+
const [width, setWidth] = useState(0);
|
|
74
|
+
const updateWidth = useCallback(() => {
|
|
75
|
+
if (!imageRef.current) return;
|
|
76
|
+
setWidth(imageRef.current.width);
|
|
77
|
+
}, []);
|
|
78
|
+
useEvent(
|
|
79
|
+
(typeof window !== 'undefined' ? window : undefined) as EventTarget,
|
|
80
|
+
'resize',
|
|
81
|
+
updateWidth
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (!src) return null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<ImageFigure width={width}>
|
|
88
|
+
{loading && (
|
|
89
|
+
<Mask>
|
|
90
|
+
<LoadingIcon />
|
|
91
|
+
</Mask>
|
|
92
|
+
)}
|
|
93
|
+
<Image
|
|
94
|
+
src={src.startsWith('blob:') ? src : `${src}-1024`}
|
|
95
|
+
alt={block.getText()}
|
|
96
|
+
onLoad={updateWidth}
|
|
97
|
+
ref={imageRef}
|
|
98
|
+
/>
|
|
99
|
+
{!loading && (
|
|
100
|
+
<FigureCaption>
|
|
101
|
+
<EditorBlock {...props} />
|
|
102
|
+
</FigureCaption>
|
|
103
|
+
)}
|
|
104
|
+
</ImageFigure>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const IMAGE_BLOCK = 'atomic:image';
|
|
109
|
+
|
|
110
|
+
const imageBlock = (
|
|
111
|
+
onImageUpload: (file: File) => Promise<string>
|
|
112
|
+
): BlockToolbarItem => ({
|
|
113
|
+
type: IMAGE_BLOCK,
|
|
114
|
+
component: ImageBlock,
|
|
115
|
+
icon: <Picture />,
|
|
116
|
+
onClick: ({ value, onChange, setReadOnly }) => {
|
|
117
|
+
if (!onImageUpload) throw new Error('Specify the onImageUpload method');
|
|
118
|
+
|
|
119
|
+
// Not working in mobile Safari.
|
|
120
|
+
// The input must be actually appended to the DOM.
|
|
121
|
+
const input = document.createElement('input');
|
|
122
|
+
input.type = 'file';
|
|
123
|
+
input.accept = 'image/jpeg,image/png,image/webp';
|
|
124
|
+
input.onchange = async (e) => {
|
|
125
|
+
const target = e.target as HTMLInputElement | null;
|
|
126
|
+
if (!target) return;
|
|
127
|
+
const { files } = target;
|
|
128
|
+
if (!files) return;
|
|
129
|
+
|
|
130
|
+
setReadOnly(true);
|
|
131
|
+
const file = files[0];
|
|
132
|
+
let src = URL.createObjectURL(file);
|
|
133
|
+
|
|
134
|
+
// Add the local image
|
|
135
|
+
const currentBlock = getCurrentBlock(value);
|
|
136
|
+
let nextEditorState = changeBlock(value, currentBlock, IMAGE_BLOCK, {
|
|
137
|
+
src,
|
|
138
|
+
loading: true,
|
|
139
|
+
});
|
|
140
|
+
onChange(nextEditorState);
|
|
141
|
+
|
|
142
|
+
// Replace the local image with the remote one
|
|
143
|
+
try {
|
|
144
|
+
src = await onImageUpload(file);
|
|
145
|
+
nextEditorState = changeBlock(value, currentBlock, IMAGE_BLOCK, {
|
|
146
|
+
src,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof Error) {
|
|
150
|
+
message.error(err.message);
|
|
151
|
+
}
|
|
152
|
+
nextEditorState = changeBlock(value, currentBlock, 'unstyled');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setReadOnly(false);
|
|
156
|
+
onChange(nextEditorState);
|
|
157
|
+
};
|
|
158
|
+
input.click();
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export default imageBlock;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ContentBlock, EditorState } from 'draft-js';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
export interface BlockProps extends Record<string, any> {
|
|
6
|
+
block: ContentBlock;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
export interface BlockToolbarItemOnClickProps extends Record<string, any> {
|
|
11
|
+
value: EditorState;
|
|
12
|
+
onChange: (value: EditorState) => void;
|
|
13
|
+
setReadOnly: (readOnly: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BlockToolbarItem {
|
|
17
|
+
type: string;
|
|
18
|
+
component: React.FC<BlockProps>;
|
|
19
|
+
icon: React.ReactElement;
|
|
20
|
+
onClick: (props: BlockToolbarItemOnClickProps) => void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Video } from '@os-design/core';
|
|
2
|
+
import { Video as VideoIcon } from '@os-design/icons';
|
|
3
|
+
import { EditorBlock } from 'draft-js';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import changeBlock from '../utils/changeBlock';
|
|
7
|
+
import getCurrentBlock from '../utils/getCurrentBlock';
|
|
8
|
+
import Figure from './Figure';
|
|
9
|
+
import FigureCaption from './FigureCaption';
|
|
10
|
+
import { BlockProps, BlockToolbarItem } from './types';
|
|
11
|
+
|
|
12
|
+
const VideoBlock: React.FC<BlockProps> = (props) => {
|
|
13
|
+
const { block } = props;
|
|
14
|
+
const data = block.getData();
|
|
15
|
+
const src = data.get('src');
|
|
16
|
+
|
|
17
|
+
if (!src) return null;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Figure>
|
|
21
|
+
<Video src={src} title={block.getText()} />
|
|
22
|
+
<FigureCaption>
|
|
23
|
+
<EditorBlock {...props} />
|
|
24
|
+
</FigureCaption>
|
|
25
|
+
</Figure>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const videoTypes = [
|
|
30
|
+
/**
|
|
31
|
+
* YouTube. Supported formats:
|
|
32
|
+
* https://www.youtube.com/watch?v=FJIhWbUt600&ab_channel=IlyaOrdin
|
|
33
|
+
* https://www.youtube.com/embed/FJIhWbUt600
|
|
34
|
+
* https://youtu.be/FJIhWbUt600
|
|
35
|
+
*/
|
|
36
|
+
{
|
|
37
|
+
re: /^https:\/\/(?:www\.youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([A-z0-9-_]*).*$/,
|
|
38
|
+
getUrl: (id: string) => `https://www.youtube.com/embed/${id}`,
|
|
39
|
+
},
|
|
40
|
+
/**
|
|
41
|
+
* RuTube. Supported formats:
|
|
42
|
+
* https://rutube.ru/video/d00526135b2b96d272f6d89b486036c1/
|
|
43
|
+
* https://rutube.ru/play/embed/d00526135b2b96d272f6d89b486036c1
|
|
44
|
+
*/
|
|
45
|
+
{
|
|
46
|
+
re: /^https:\/\/rutube\.ru\/(?:video|play\/embed)\/([a-z0-9]*)\/?$/,
|
|
47
|
+
getUrl: (id: string) => `https://rutube.ru/play/embed/${id}`,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const detectVideo = (url: string) => {
|
|
52
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
53
|
+
for (const { re, getUrl } of videoTypes) {
|
|
54
|
+
const groups = url.match(re);
|
|
55
|
+
if (groups && groups[1]) return getUrl(groups[1]);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const VIDEO_BLOCK = 'atomic:video';
|
|
61
|
+
|
|
62
|
+
const videoBlock: BlockToolbarItem = {
|
|
63
|
+
type: VIDEO_BLOCK,
|
|
64
|
+
component: VideoBlock,
|
|
65
|
+
icon: <VideoIcon />,
|
|
66
|
+
onClick: ({ value, onChange }) => {
|
|
67
|
+
// eslint-disable-next-line no-alert
|
|
68
|
+
const url = prompt('Insert a link to YouTube or RuTube');
|
|
69
|
+
if (!url) return;
|
|
70
|
+
|
|
71
|
+
const src = detectVideo(url);
|
|
72
|
+
if (!src) return;
|
|
73
|
+
|
|
74
|
+
const currentBlock = getCurrentBlock(value);
|
|
75
|
+
const nextEditorState = changeBlock(value, currentBlock, VIDEO_BLOCK, {
|
|
76
|
+
src,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onChange(nextEditorState);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default videoBlock;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Link } from '@os-design/core';
|
|
2
|
+
|
|
3
|
+
import { DraftDecorator } from 'draft-js';
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
const linkDecorator: DraftDecorator = {
|
|
8
|
+
strategy: (contentBlock, callback, contentState) =>
|
|
9
|
+
contentBlock.findEntityRanges((character) => {
|
|
10
|
+
const entityKey = character.getEntity();
|
|
11
|
+
return (
|
|
12
|
+
entityKey !== null &&
|
|
13
|
+
contentState.getEntity(entityKey).getType() === 'LINK'
|
|
14
|
+
);
|
|
15
|
+
}, callback),
|
|
16
|
+
|
|
17
|
+
component: ({ contentState, entityKey, children }) => {
|
|
18
|
+
const { url } = contentState.getEntity(entityKey).getData();
|
|
19
|
+
return <Link href={url}>{children}</Link>;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default linkDecorator;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { EditorState } from 'draft-js';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import getSelectedBlockElement from '../utils/getSelectedBlockElement';
|
|
4
|
+
|
|
5
|
+
interface Rect {
|
|
6
|
+
top: number;
|
|
7
|
+
left: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseBlockToolbarPropsRes {
|
|
13
|
+
trigger: Rect;
|
|
14
|
+
visible: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Updates the visibility of the block toolbar.
|
|
19
|
+
*/
|
|
20
|
+
const useBlockToolbarProps = (
|
|
21
|
+
value: EditorState,
|
|
22
|
+
show: boolean
|
|
23
|
+
): UseBlockToolbarPropsRes => {
|
|
24
|
+
const [trigger, setTrigger] = useState({
|
|
25
|
+
top: 0,
|
|
26
|
+
left: 0,
|
|
27
|
+
width: 0,
|
|
28
|
+
height: 0,
|
|
29
|
+
});
|
|
30
|
+
const [visible, setVisible] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!show || !value) return;
|
|
34
|
+
|
|
35
|
+
const selectedBlockElement = getSelectedBlockElement();
|
|
36
|
+
if (!selectedBlockElement) {
|
|
37
|
+
setVisible(false);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const selectionState = value.getSelection();
|
|
42
|
+
const currentBlockKey = selectionState.getStartKey();
|
|
43
|
+
const contentState = value.getCurrentContent();
|
|
44
|
+
const currentBlock = contentState.getBlockForKey(currentBlockKey);
|
|
45
|
+
const lineNumber = contentState
|
|
46
|
+
.getBlockMap()
|
|
47
|
+
.keySeq()
|
|
48
|
+
.findIndex((k) => k === currentBlockKey);
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
currentBlock.getType() !== 'unstyled' ||
|
|
52
|
+
currentBlock.getLength() > 0 ||
|
|
53
|
+
lineNumber === 0
|
|
54
|
+
) {
|
|
55
|
+
setVisible(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { top, left, height } = selectedBlockElement.getBoundingClientRect();
|
|
60
|
+
setTrigger({
|
|
61
|
+
top,
|
|
62
|
+
left,
|
|
63
|
+
width: 0,
|
|
64
|
+
height,
|
|
65
|
+
});
|
|
66
|
+
setVisible(true);
|
|
67
|
+
}, [show, value]);
|
|
68
|
+
|
|
69
|
+
return { trigger, visible };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default useBlockToolbarProps;
|