mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support image block (#4783)
This commit is contained in:
parent
37bc5b3fbf
commit
835563f81b
@ -73,6 +73,7 @@
|
|||||||
"slate-history": "^0.100.0",
|
"slate-history": "^0.100.0",
|
||||||
"slate-react": "^0.101.3",
|
"slate-react": "^0.101.3",
|
||||||
"ts-results": "^3.3.0",
|
"ts-results": "^3.3.0",
|
||||||
|
"unsplash-js": "^7.0.19",
|
||||||
"utf8": "^3.0.0",
|
"utf8": "^3.0.0",
|
||||||
"valtio": "^1.12.1",
|
"valtio": "^1.12.1",
|
||||||
"yjs": "^13.5.51"
|
"yjs": "^13.5.51"
|
||||||
|
@ -166,6 +166,9 @@ dependencies:
|
|||||||
ts-results:
|
ts-results:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
|
unsplash-js:
|
||||||
|
specifier: ^7.0.19
|
||||||
|
version: 7.0.19
|
||||||
utf8:
|
utf8:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@ -6853,6 +6856,11 @@ packages:
|
|||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/unsplash-js@7.0.19:
|
||||||
|
resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/update-browserslist-db@1.0.11(browserslist@4.21.5):
|
/update-browserslist-db@1.0.11(browserslist@4.21.5):
|
||||||
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
|
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
@ -109,6 +109,22 @@ export interface MathEquationNode extends Element {
|
|||||||
} & BlockData;
|
} & BlockData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ImageType {
|
||||||
|
Internal = 1,
|
||||||
|
External = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageNode extends Element {
|
||||||
|
type: EditorNodeType.ImageBlock;
|
||||||
|
blockId: string;
|
||||||
|
data: {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
image_type?: ImageType;
|
||||||
|
height?: number;
|
||||||
|
} & BlockData;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormulaNode extends Element {
|
export interface FormulaNode extends Element {
|
||||||
type: EditorInlineNodeType.Formula;
|
type: EditorInlineNodeType.Formula;
|
||||||
data: string;
|
data: string;
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.5 1H12.5C13.3284 1 14 1.67157 14 2.5V12.5C14 13.3284 13.3284 14 12.5 14H2.5C1.67157 14 1 13.3284 1 12.5V2.5C1 1.67157 1.67157 1 2.5 1ZM2.5 2C2.22386 2 2 2.22386 2 2.5V8.3636L3.6818 6.6818C3.76809 6.59551 3.88572 6.54797 4.00774 6.55007C4.12975 6.55216 4.24568 6.60372 4.32895 6.69293L7.87355 10.4901L10.6818 7.6818C10.8575 7.50607 11.1425 7.50607 11.3182 7.6818L13 9.3636V2.5C13 2.22386 12.7761 2 12.5 2H2.5ZM2 12.5V9.6364L3.98887 7.64753L7.5311 11.4421L8.94113 13H2.5C2.22386 13 2 12.7761 2 12.5ZM12.5 13H10.155L8.48336 11.153L11 8.6364L13 10.6364V12.5C13 12.7761 12.7761 13 12.5 13ZM6.64922 5.5C6.64922 5.03013 7.03013 4.64922 7.5 4.64922C7.96987 4.64922 8.35078 5.03013 8.35078 5.5C8.35078 5.96987 7.96987 6.35078 7.5 6.35078C7.03013 6.35078 6.64922 5.96987 6.64922 5.5ZM7.5 3.74922C6.53307 3.74922 5.74922 4.53307 5.74922 5.5C5.74922 6.46693 6.53307 7.25078 7.5 7.25078C8.46693 7.25078 9.25078 6.46693 9.25078 5.5C9.25078 4.53307 8.46693 3.74922 7.5 3.74922Z" fill="black"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function Colors() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Colors;
|
@ -0,0 +1,61 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { pattern } from '$app/utils/open_url';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
|
export function EmbedLink({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
setValue(value);
|
||||||
|
setError(!pattern.test(value));
|
||||||
|
},
|
||||||
|
[setValue, setError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !error && value) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDone?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onEscape?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[error, onDone, onEscape, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div tabIndex={0} onKeyDown={handleKeyDown} className={'flex flex-col items-center gap-4 px-4 pb-4'}>
|
||||||
|
<TextField
|
||||||
|
error={error}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size={'small'}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={handleChange}
|
||||||
|
helperText={error ? t('editor.incorrectLink') : ''}
|
||||||
|
value={value}
|
||||||
|
placeholder={t('document.imageBlock.embedLink.placeholder')}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button variant={'contained'} className={'w-3/5'} onClick={() => onDone?.(value)} disabled={error || !value}>
|
||||||
|
{t('document.imageBlock.embedLink.label')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmbedLink;
|
@ -0,0 +1,154 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createApi } from 'unsplash-js';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { open } from '@tauri-apps/api/shell';
|
||||||
|
|
||||||
|
const unsplash = createApi({
|
||||||
|
accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids',
|
||||||
|
});
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_TIME = 500;
|
||||||
|
|
||||||
|
export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [photos, setPhotos] = useState<
|
||||||
|
{
|
||||||
|
thumb: string;
|
||||||
|
regular: string;
|
||||||
|
alt: string | null;
|
||||||
|
id: string;
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
setSearchValue(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onEscape?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEscape]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debounceSearchPhotos = useMemo(() => {
|
||||||
|
return debounce(async (searchValue: string) => {
|
||||||
|
const request = searchValue
|
||||||
|
? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 })
|
||||||
|
: unsplash.photos.list({ perPage: 32 });
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
await request.then((result) => {
|
||||||
|
if (result.errors) {
|
||||||
|
setError(result.errors[0]);
|
||||||
|
} else {
|
||||||
|
setPhotos(
|
||||||
|
result.response.results.map((photo) => ({
|
||||||
|
id: photo.id,
|
||||||
|
thumb: photo.urls.thumb,
|
||||||
|
regular: photo.urls.regular,
|
||||||
|
alt: photo.alt_description,
|
||||||
|
user: {
|
||||||
|
name: photo.user.name,
|
||||||
|
link: photo.user.links.html,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, SEARCH_DEBOUNCE_TIME);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void debounceSearchPhotos(searchValue);
|
||||||
|
return () => {
|
||||||
|
debounceSearchPhotos.cancel();
|
||||||
|
};
|
||||||
|
}, [debounceSearchPhotos, searchValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div tabIndex={0} onKeyDown={handleKeyDown} className={'flex min-h-[200px] flex-col gap-4 px-4 pb-4'}>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size={'small'}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={handleChange}
|
||||||
|
value={searchValue}
|
||||||
|
placeholder={t('document.imageBlock.searchForAnImage')}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs'}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<div className={'text-xs text-text-caption'}>{t('editor.loading')}</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Typography className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs text-function-error'}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<div className={'flex flex-col gap-4'}>
|
||||||
|
{photos.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className={'flex w-full flex-1 flex-wrap gap-2'}>
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<div key={photo.id} className={'flex cursor-pointer flex-col gap-2'}>
|
||||||
|
<img
|
||||||
|
onClick={() => {
|
||||||
|
onDone?.(photo.regular);
|
||||||
|
}}
|
||||||
|
src={photo.thumb}
|
||||||
|
alt={photo.alt ?? ''}
|
||||||
|
className={'h-20 w-32 rounded object-cover hover:opacity-80'}
|
||||||
|
/>
|
||||||
|
<div className={'w-32 truncate text-xs text-text-caption'}>
|
||||||
|
by{' '}
|
||||||
|
<span
|
||||||
|
onClick={() => {
|
||||||
|
void open(photo.user.link);
|
||||||
|
}}
|
||||||
|
className={'underline hover:text-function-info'}
|
||||||
|
>
|
||||||
|
{photo.user.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Typography className={'w-full text-center text-xs text-text-caption'}>
|
||||||
|
{t('findAndReplace.searchMore')}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography className={'flex h-[120px] w-full items-center justify-center gap-2 text-xs text-text-caption'}>
|
||||||
|
{t('findAndReplace.noResult')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function UploadImage() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadImage;
|
@ -0,0 +1,4 @@
|
|||||||
|
export * from './Unsplash';
|
||||||
|
export * from './UploadImage';
|
||||||
|
export * from './EmbedLink';
|
||||||
|
export * from './Colors';
|
@ -30,7 +30,9 @@ function getOffsetLeft(
|
|||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
},
|
},
|
||||||
horizontal: number | 'center' | 'left' | 'right'
|
paperWidth: number,
|
||||||
|
horizontal: number | 'center' | 'left' | 'right',
|
||||||
|
transformHorizontal: number | 'center' | 'left' | 'right'
|
||||||
) {
|
) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
@ -42,6 +44,12 @@ function getOffsetLeft(
|
|||||||
offset = rect.width;
|
offset = rect.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transformHorizontal === 'center') {
|
||||||
|
offset -= paperWidth / 2;
|
||||||
|
} else if (transformHorizontal === 'right') {
|
||||||
|
offset -= paperWidth;
|
||||||
|
}
|
||||||
|
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +58,9 @@ function getOffsetTop(
|
|||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
},
|
},
|
||||||
vertical: number | 'center' | 'bottom' | 'top'
|
papertHeight: number,
|
||||||
|
vertical: number | 'center' | 'bottom' | 'top',
|
||||||
|
transformVertical: number | 'center' | 'bottom' | 'top'
|
||||||
) {
|
) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
@ -62,6 +72,12 @@ function getOffsetTop(
|
|||||||
offset = rect.height;
|
offset = rect.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transformVertical === 'center') {
|
||||||
|
offset -= papertHeight / 2;
|
||||||
|
} else if (transformVertical === 'bottom') {
|
||||||
|
offset -= papertHeight;
|
||||||
|
}
|
||||||
|
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,8 +138,12 @@ const usePopoverAutoPosition = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// calculate new paper width
|
// calculate new paper width
|
||||||
const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal);
|
const newLeft =
|
||||||
const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical);
|
anchorRect.left +
|
||||||
|
getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal);
|
||||||
|
const newTop =
|
||||||
|
anchorRect.top +
|
||||||
|
getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical);
|
||||||
|
|
||||||
let isExceedViewportRight = false;
|
let isExceedViewportRight = false;
|
||||||
let isExceedViewportBottom = false;
|
let isExceedViewportBottom = false;
|
||||||
|
@ -30,6 +30,7 @@ import {
|
|||||||
ToggleListNode,
|
ToggleListNode,
|
||||||
inlineNodeTypes,
|
inlineNodeTypes,
|
||||||
FormulaNode,
|
FormulaNode,
|
||||||
|
ImageNode,
|
||||||
} from '$app/application/document/document.types';
|
} from '$app/application/document/document.types';
|
||||||
import cloneDeep from 'lodash-es/cloneDeep';
|
import cloneDeep from 'lodash-es/cloneDeep';
|
||||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||||
@ -39,6 +40,7 @@ export const EmbedTypes: string[] = [
|
|||||||
EditorNodeType.DividerBlock,
|
EditorNodeType.DividerBlock,
|
||||||
EditorNodeType.EquationBlock,
|
EditorNodeType.EquationBlock,
|
||||||
EditorNodeType.GridBlock,
|
EditorNodeType.GridBlock,
|
||||||
|
EditorNodeType.ImageBlock,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CustomEditor = {
|
export const CustomEditor = {
|
||||||
@ -120,7 +122,7 @@ export const CustomEditor = {
|
|||||||
at: path,
|
at: path,
|
||||||
});
|
});
|
||||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||||
return;
|
return cloneNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
||||||
@ -148,6 +150,8 @@ export const CustomEditor = {
|
|||||||
if (selection) {
|
if (selection) {
|
||||||
editor.select(selection);
|
editor.select(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cloneNode;
|
||||||
},
|
},
|
||||||
tabForward,
|
tabForward,
|
||||||
tabBackward,
|
tabBackward,
|
||||||
@ -346,6 +350,19 @@ export const CustomEditor = {
|
|||||||
Transforms.setNodes(editor, newProperties, { at: path });
|
Transforms.setNodes(editor, newProperties, { at: path });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setImageBlockData(editor: ReactEditor, node: Element, newData: ImageNode['data']) {
|
||||||
|
const path = ReactEditor.findPath(editor, node);
|
||||||
|
const data = node.data || {};
|
||||||
|
const newProperties = {
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
...newData,
|
||||||
|
},
|
||||||
|
} as Partial<Element>;
|
||||||
|
|
||||||
|
Transforms.setNodes(editor, newProperties, { at: path });
|
||||||
|
},
|
||||||
|
|
||||||
cloneBlock(editor: ReactEditor, block: Element): Element {
|
cloneBlock(editor: ReactEditor, block: Element): Element {
|
||||||
const cloneNode: Element = {
|
const cloneNode: Element = {
|
||||||
...cloneDeep(block),
|
...cloneDeep(block),
|
||||||
|
@ -0,0 +1,163 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { ImageNode } from '$app/application/document/document.types';
|
||||||
|
import { ReactComponent as CopyIcon } from '$app/assets/copy.svg';
|
||||||
|
import { ReactComponent as AlignLeftIcon } from '$app/assets/align-left.svg';
|
||||||
|
import { ReactComponent as AlignCenterIcon } from '$app/assets/align-center.svg';
|
||||||
|
import { ReactComponent as AlignRightIcon } from '$app/assets/align-right.svg';
|
||||||
|
import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { IconButton } from '@mui/material';
|
||||||
|
import { notify } from '$app/components/_shared/notify';
|
||||||
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
|
import { useSlateStatic } from 'slate-react';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
|
enum ImageAction {
|
||||||
|
Copy = 'copy',
|
||||||
|
AlignLeft = 'left',
|
||||||
|
AlignCenter = 'center',
|
||||||
|
AlignRight = 'right',
|
||||||
|
Delete = 'delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageActions({ node }: { node: ImageNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const align = node.data.align;
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const [alignAnchorEl, setAlignAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const alignOptions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: ImageAction.AlignLeft,
|
||||||
|
Icon: AlignLeftIcon,
|
||||||
|
onClick: () => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, { align: 'left' });
|
||||||
|
setAlignAnchorEl(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ImageAction.AlignCenter,
|
||||||
|
Icon: AlignCenterIcon,
|
||||||
|
onClick: () => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, { align: 'center' });
|
||||||
|
setAlignAnchorEl(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ImageAction.AlignRight,
|
||||||
|
Icon: AlignRightIcon,
|
||||||
|
onClick: () => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, { align: 'right' });
|
||||||
|
setAlignAnchorEl(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [editor, node]);
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: ImageAction.Copy,
|
||||||
|
Icon: CopyIcon,
|
||||||
|
tooltip: t('button.copyLink'),
|
||||||
|
onClick: () => {
|
||||||
|
if (!node.data.url) return;
|
||||||
|
void navigator.clipboard.writeText(node.data.url);
|
||||||
|
notify.success(t('message.copy.success'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(!align || align === 'left') && {
|
||||||
|
key: ImageAction.AlignLeft,
|
||||||
|
Icon: AlignLeftIcon,
|
||||||
|
tooltip: t('button.align'),
|
||||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAlignAnchorEl(e.currentTarget);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
align === 'center' && {
|
||||||
|
key: ImageAction.AlignCenter,
|
||||||
|
Icon: AlignCenterIcon,
|
||||||
|
tooltip: t('button.align'),
|
||||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAlignAnchorEl(e.currentTarget);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
align === 'right' && {
|
||||||
|
key: ImageAction.AlignRight,
|
||||||
|
Icon: AlignRightIcon,
|
||||||
|
tooltip: t('button.align'),
|
||||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAlignAnchorEl(e.currentTarget);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ImageAction.Delete,
|
||||||
|
Icon: DeleteIcon,
|
||||||
|
tooltip: t('button.delete'),
|
||||||
|
onClick: () => {
|
||||||
|
CustomEditor.deleteNode(editor, node);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter(Boolean) as {
|
||||||
|
key: ImageAction;
|
||||||
|
Icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||||
|
tooltip: string;
|
||||||
|
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
}[];
|
||||||
|
}, [align, node, t, editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'absolute right-1 top-1 flex items-center justify-between rounded bg-bg-body shadow-lg'}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const { key, Icon, tooltip, onClick } = option;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip disableInteractive={true} placement={'top'} title={tooltip} key={key}>
|
||||||
|
<IconButton
|
||||||
|
size={'small'}
|
||||||
|
className={'bg-transparent p-2 text-icon-primary hover:text-fill-default'}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!!alignAnchorEl && (
|
||||||
|
<Popover
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
open={!!alignAnchorEl}
|
||||||
|
anchorEl={alignAnchorEl}
|
||||||
|
onClose={() => setAlignAnchorEl(null)}
|
||||||
|
>
|
||||||
|
{alignOptions.map((option) => {
|
||||||
|
const { key, Icon, onClick } = option;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={key}
|
||||||
|
size={'small'}
|
||||||
|
style={{
|
||||||
|
color: align === key ? 'var(--fill-default)' : undefined,
|
||||||
|
}}
|
||||||
|
className={'bg-transparent p-2 text-icon-primary hover:text-fill-default'}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageActions;
|
@ -0,0 +1,49 @@
|
|||||||
|
import React, { forwardRef, memo, useCallback, useRef } from 'react';
|
||||||
|
import { EditorElementProps, ImageNode } from '$app/application/document/document.types';
|
||||||
|
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||||
|
import ImageRender from '$app/components/editor/components/blocks/image/ImageRender';
|
||||||
|
import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpty';
|
||||||
|
|
||||||
|
export const ImageBlock = memo(
|
||||||
|
forwardRef<HTMLDivElement, EditorElementProps<ImageNode>>(({ node, children, className, ...attributes }, ref) => {
|
||||||
|
const selected = useSelected();
|
||||||
|
const { url, align } = node.data;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const onFocusNode = useCallback(() => {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
const path = ReactEditor.findPath(editor, node);
|
||||||
|
|
||||||
|
editor.select(path);
|
||||||
|
}, [editor, node]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
ref={containerRef}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selected) onFocusNode();
|
||||||
|
}}
|
||||||
|
className={`${className} image-block relative w-full cursor-pointer py-1`}
|
||||||
|
>
|
||||||
|
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
contentEditable={false}
|
||||||
|
className={`flex w-full select-none ${url ? '' : 'rounded border'} ${
|
||||||
|
selected ? 'border-fill-list-hover' : 'border-line-divider'
|
||||||
|
} ${align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
<ImageRender selected={selected} node={node} />
|
||||||
|
) : (
|
||||||
|
<ImageEmpty node={node} onEscape={onFocusNode} containerRef={containerRef} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ImageBlock;
|
@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import UploadPopover from '$app/components/editor/components/blocks/image/UploadPopover';
|
||||||
|
import { EditorNodeType, ImageNode } from '$app/application/document/document.types';
|
||||||
|
import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block';
|
||||||
|
|
||||||
|
function ImageEmpty({
|
||||||
|
containerRef,
|
||||||
|
onEscape,
|
||||||
|
node,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
onEscape: () => void;
|
||||||
|
node: ImageNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const state = useEditorBlockState(EditorNodeType.ImageBlock);
|
||||||
|
const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current);
|
||||||
|
const { openPopover, closePopover } = useEditorBlockDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
openPopover(EditorNodeType.ImageBlock, node.blockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('click', handleClick);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('click', handleClick);
|
||||||
|
};
|
||||||
|
}, [containerRef, node.blockId, openPopover]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-[48px] w-full cursor-pointer items-center gap-[10px] bg-content-blue-50 px-4 text-text-caption'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ImageIcon />
|
||||||
|
{t('document.plugins.image.addAnImage')}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<UploadPopover
|
||||||
|
anchorEl={containerRef.current}
|
||||||
|
open={open}
|
||||||
|
node={node}
|
||||||
|
onClose={() => {
|
||||||
|
closePopover(EditorNodeType.ImageBlock);
|
||||||
|
onEscape();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageEmpty;
|
@ -0,0 +1,91 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ImageNode } from '$app/application/document/document.types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { ErrorOutline } from '@mui/icons-material';
|
||||||
|
import ImageResizer from '$app/components/editor/components/blocks/image/ImageResizer';
|
||||||
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
|
import { useSlateStatic } from 'slate-react';
|
||||||
|
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
|
||||||
|
|
||||||
|
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const { url, width: imageWidth } = node.data;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const blockId = node.blockId;
|
||||||
|
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const [initialWidth, setInitialWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleWidthChange = useCallback(
|
||||||
|
(newWidth: number) => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, {
|
||||||
|
width: newWidth,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editor, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !hasError && initialWidth === null && imgRef.current) {
|
||||||
|
setInitialWidth(imgRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
}, [hasError, initialWidth, loading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setShowActions(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setShowActions(false);
|
||||||
|
}}
|
||||||
|
className={'relative'}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
draggable={false}
|
||||||
|
loading={'lazy'}
|
||||||
|
onLoad={() => {
|
||||||
|
setHasError(false);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setHasError(true);
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
src={url}
|
||||||
|
alt={`image-${blockId}`}
|
||||||
|
className={'object-cover'}
|
||||||
|
style={{ width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 }}
|
||||||
|
/>
|
||||||
|
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
||||||
|
{showActions && <ImageActions node={node} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className={'flex h-[48px] w-full items-center justify-center gap-2 rounded bg-gray-100'}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<div className={'text-text-caption'}>{t('editor.loading')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasError && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-[48px] w-full items-center justify-center gap-2 rounded border border-function-error bg-red-50'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ErrorOutline className={'text-function-error'} />
|
||||||
|
<div className={'text-function-error'}>{t('editor.imageLoadFailed')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageRender;
|
@ -0,0 +1,54 @@
|
|||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
const MIN_WIDTH = 80;
|
||||||
|
|
||||||
|
function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange: (newWidth: number) => void }) {
|
||||||
|
const originalWidth = useRef(width);
|
||||||
|
const startX = useRef(0);
|
||||||
|
|
||||||
|
const onResize = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const diff = e.clientX - startX.current;
|
||||||
|
const newWidth = originalWidth.current + diff;
|
||||||
|
|
||||||
|
if (newWidth < MIN_WIDTH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onWidthChange(newWidth);
|
||||||
|
},
|
||||||
|
[onWidthChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onResizeEnd = useCallback(() => {
|
||||||
|
document.removeEventListener('mousemove', onResize);
|
||||||
|
document.removeEventListener('mouseup', onResizeEnd);
|
||||||
|
}, [onResize]);
|
||||||
|
|
||||||
|
const onResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
startX.current = e.clientX;
|
||||||
|
document.addEventListener('mousemove', onResize);
|
||||||
|
document.addEventListener('mouseup', onResizeEnd);
|
||||||
|
},
|
||||||
|
[onResize, onResizeEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={onResizeStart}
|
||||||
|
onMouseUp={() => {
|
||||||
|
originalWidth.current = width;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
right: '2px',
|
||||||
|
}}
|
||||||
|
className={'image-resizer'}
|
||||||
|
>
|
||||||
|
<div className={'resize-handle'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageResizer;
|
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useCallback, useMemo, SyntheticEvent, useState } from 'react';
|
||||||
|
import Popover, { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks';
|
||||||
|
import { PopoverCommonProps } from '$app/components/editor/components/tools/popover';
|
||||||
|
import { TabPanel, ViewTab, ViewTabs } from '$app/components/database/components/tab_bar/ViewTabs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EmbedLink, Unsplash } from '$app/components/_shared/image_upload';
|
||||||
|
import SwipeableViews from 'react-swipeable-views';
|
||||||
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
|
import { useSlateStatic } from 'slate-react';
|
||||||
|
import { ImageNode, ImageType } from '$app/application/document/document.types';
|
||||||
|
|
||||||
|
enum TAB_KEY {
|
||||||
|
UPLOAD = 'upload',
|
||||||
|
EMBED_LINK = 'embed_link',
|
||||||
|
UNSPLASH = 'unsplash',
|
||||||
|
}
|
||||||
|
const initialOrigin: {
|
||||||
|
transformOrigin: PopoverOrigin;
|
||||||
|
anchorOrigin: PopoverOrigin;
|
||||||
|
} = {
|
||||||
|
transformOrigin: {
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center',
|
||||||
|
},
|
||||||
|
anchorOrigin: {
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function UploadPopover({
|
||||||
|
open,
|
||||||
|
anchorEl,
|
||||||
|
onClose,
|
||||||
|
node,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
anchorEl: HTMLDivElement | null;
|
||||||
|
onClose: () => void;
|
||||||
|
node: ImageNode;
|
||||||
|
}) {
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { transformOrigin, anchorOrigin, isEntered, paperHeight, paperWidth } = usePopoverAutoPosition({
|
||||||
|
initialPaperWidth: 433,
|
||||||
|
initialPaperHeight: 300,
|
||||||
|
anchorEl,
|
||||||
|
initialAnchorOrigin: initialOrigin.anchorOrigin,
|
||||||
|
initialTransformOrigin: initialOrigin.transformOrigin,
|
||||||
|
open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabOptions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
// {
|
||||||
|
// label: t('button.upload'),
|
||||||
|
// key: TAB_KEY.UPLOAD,
|
||||||
|
// Component: UploadImage,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
label: t('document.imageBlock.embedLink.label'),
|
||||||
|
key: TAB_KEY.EMBED_LINK,
|
||||||
|
Component: EmbedLink,
|
||||||
|
onDone: (link: string) => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, {
|
||||||
|
url: link,
|
||||||
|
image_type: ImageType.External,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: TAB_KEY.UNSPLASH,
|
||||||
|
label: t('document.imageBlock.unsplash.label'),
|
||||||
|
Component: Unsplash,
|
||||||
|
onDone: (link: string) => {
|
||||||
|
CustomEditor.setImageBlockData(editor, node, {
|
||||||
|
url: link,
|
||||||
|
image_type: ImageType.External,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [editor, node, onClose, t]);
|
||||||
|
|
||||||
|
const [tabValue, setTabValue] = useState<TAB_KEY>(tabOptions[0].key);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => {
|
||||||
|
setTabValue(newValue as TAB_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setTabValue((prev) => {
|
||||||
|
const currentIndex = tabOptions.findIndex((tab) => tab.key === prev);
|
||||||
|
const nextIndex = (currentIndex + 1) % tabOptions.length;
|
||||||
|
|
||||||
|
return tabOptions[nextIndex]?.key ?? tabOptions[0].key;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose, tabOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
{...PopoverCommonProps}
|
||||||
|
disableAutoFocus={false}
|
||||||
|
open={open && isEntered}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
transformOrigin={transformOrigin}
|
||||||
|
anchorOrigin={anchorOrigin}
|
||||||
|
onClose={onClose}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
PaperProps={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: paperWidth,
|
||||||
|
maxHeight: paperHeight,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
className={'flex flex-col gap-4'}
|
||||||
|
>
|
||||||
|
<ViewTabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
scrollButtons={false}
|
||||||
|
variant='scrollable'
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
className={'min-h-[38px] border-b border-line-divider px-2'}
|
||||||
|
>
|
||||||
|
{tabOptions.map((tab) => {
|
||||||
|
const { key, label } = tab;
|
||||||
|
|
||||||
|
return <ViewTab key={key} iconPosition='start' color='inherit' label={label} value={key} />;
|
||||||
|
})}
|
||||||
|
</ViewTabs>
|
||||||
|
|
||||||
|
<div className={'h-full w-full flex-1 overflow-y-auto overflow-x-hidden'}>
|
||||||
|
<SwipeableViews
|
||||||
|
slideStyle={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
axis={'x'}
|
||||||
|
index={selectedIndex}
|
||||||
|
>
|
||||||
|
{tabOptions.map((tab, index) => {
|
||||||
|
const { key, Component, onDone } = tab;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel className={'flex h-full w-full flex-col'} key={key} index={index} value={selectedIndex}>
|
||||||
|
<Component onDone={onDone} onEscape={onClose} />
|
||||||
|
</TabPanel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SwipeableViews>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadPopover;
|
@ -0,0 +1 @@
|
|||||||
|
export * from './ImageBlock';
|
@ -1,10 +1,11 @@
|
|||||||
import { forwardRef, memo, useEffect, useRef, useState } from 'react';
|
import { forwardRef, memo, useEffect, useRef } from 'react';
|
||||||
import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types';
|
import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types';
|
||||||
import KatexMath from '$app/components/_shared/katex_math/KatexMath';
|
import KatexMath from '$app/components/_shared/katex_math/KatexMath';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FunctionsOutlined } from '@mui/icons-material';
|
import { FunctionsOutlined } from '@mui/icons-material';
|
||||||
import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover';
|
import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover';
|
||||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||||
|
import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block';
|
||||||
|
|
||||||
export const MathEquation = memo(
|
export const MathEquation = memo(
|
||||||
forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>(
|
forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>(
|
||||||
@ -12,7 +13,9 @@ export const MathEquation = memo(
|
|||||||
const formula = node.data.formula;
|
const formula = node.data.formula;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const { openPopover, closePopover } = useEditorBlockDispatch();
|
||||||
|
const state = useEditorBlockState(EditorNodeType.EquationBlock);
|
||||||
|
const open = Boolean(state?.popoverOpen && state?.blockId === node.blockId && containerRef.current);
|
||||||
|
|
||||||
const selected = useSelected();
|
const selected = useSelected();
|
||||||
|
|
||||||
@ -26,7 +29,7 @@ export const MathEquation = memo(
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(true);
|
openPopover(EditorNodeType.EquationBlock, node.blockId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -37,7 +40,7 @@ export const MathEquation = memo(
|
|||||||
return () => {
|
return () => {
|
||||||
slateDom.removeEventListener('keydown', handleKeyDown);
|
slateDom.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [editor, selected]);
|
}, [editor, node.blockId, openPopover, selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -45,9 +48,9 @@ export const MathEquation = memo(
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
openPopover(EditorNodeType.EquationBlock, node.blockId);
|
||||||
}}
|
}}
|
||||||
className={`${className} relative w-full cursor-pointer py-2`}
|
className={`${className} math-equation-block relative w-full cursor-pointer py-2`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
@ -71,7 +74,7 @@ export const MathEquation = memo(
|
|||||||
{open && (
|
{open && (
|
||||||
<EditPopover
|
<EditPopover
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setOpen(false);
|
closePopover(EditorNodeType.EquationBlock);
|
||||||
}}
|
}}
|
||||||
node={node}
|
node={node}
|
||||||
open={open}
|
open={open}
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
EditorInlineBlockStateProvider,
|
EditorInlineBlockStateProvider,
|
||||||
} from '$app/components/editor/stores';
|
} from '$app/components/editor/stores';
|
||||||
import CommandPanel from '../tools/command_panel/CommandPanel';
|
import CommandPanel from '../tools/command_panel/CommandPanel';
|
||||||
|
import { EditorBlockStateProvider } from '$app/components/editor/stores/block';
|
||||||
|
|
||||||
function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: string; disableFocus?: boolean }) {
|
function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: string; disableFocus?: boolean }) {
|
||||||
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
|
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
|
||||||
@ -33,6 +34,7 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
|
|||||||
decorateState,
|
decorateState,
|
||||||
slashState,
|
slashState,
|
||||||
inlineBlockState,
|
inlineBlockState,
|
||||||
|
blockState,
|
||||||
} = useInitialEditorState(editor);
|
} = useInitialEditorState(editor);
|
||||||
|
|
||||||
const decorate = useCallback(
|
const decorate = useCallback(
|
||||||
@ -60,24 +62,26 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
|
|||||||
return (
|
return (
|
||||||
<EditorSelectedBlockProvider value={selectedBlocks}>
|
<EditorSelectedBlockProvider value={selectedBlocks}>
|
||||||
<DecorateStateProvider value={decorateState}>
|
<DecorateStateProvider value={decorateState}>
|
||||||
<EditorInlineBlockStateProvider value={inlineBlockState}>
|
<EditorBlockStateProvider value={blockState}>
|
||||||
<SlashStateProvider value={slashState}>
|
<EditorInlineBlockStateProvider value={inlineBlockState}>
|
||||||
<Slate editor={editor} initialValue={initialValue}>
|
<SlashStateProvider value={slashState}>
|
||||||
<BlockActionsToolbar />
|
<Slate editor={editor} initialValue={initialValue}>
|
||||||
<SelectionToolbar />
|
<BlockActionsToolbar />
|
||||||
|
<SelectionToolbar />
|
||||||
|
|
||||||
<CustomEditable
|
<CustomEditable
|
||||||
{...props}
|
{...props}
|
||||||
disableFocus={disableFocus}
|
disableFocus={disableFocus}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
decorate={decorate}
|
decorate={decorate}
|
||||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||||
/>
|
/>
|
||||||
<CommandPanel />
|
<CommandPanel />
|
||||||
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
||||||
</Slate>
|
</Slate>
|
||||||
</SlashStateProvider>
|
</SlashStateProvider>
|
||||||
</EditorInlineBlockStateProvider>
|
</EditorInlineBlockStateProvider>
|
||||||
|
</EditorBlockStateProvider>
|
||||||
</DecorateStateProvider>
|
</DecorateStateProvider>
|
||||||
</EditorSelectedBlockProvider>
|
</EditorSelectedBlockProvider>
|
||||||
);
|
);
|
||||||
|
@ -21,6 +21,8 @@ import { Callout } from '$app/components/editor/components/blocks/callout';
|
|||||||
import { Mention } from '$app/components/editor/components/inline_nodes/mention';
|
import { Mention } from '$app/components/editor/components/inline_nodes/mention';
|
||||||
import { GridBlock } from '$app/components/editor/components/blocks/database';
|
import { GridBlock } from '$app/components/editor/components/blocks/database';
|
||||||
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
|
import { MathEquation } from '$app/components/editor/components/blocks/math_equation';
|
||||||
|
import { ImageBlock } from '$app/components/editor/components/blocks/image';
|
||||||
|
|
||||||
import { Text as TextComponent } from '../blocks/text';
|
import { Text as TextComponent } from '../blocks/text';
|
||||||
import { Page } from '../blocks/page';
|
import { Page } from '../blocks/page';
|
||||||
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
|
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
|
||||||
@ -68,6 +70,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
|||||||
return GridBlock;
|
return GridBlock;
|
||||||
case EditorNodeType.EquationBlock:
|
case EditorNodeType.EquationBlock:
|
||||||
return MathEquation;
|
return MathEquation;
|
||||||
|
case EditorNodeType.ImageBlock:
|
||||||
|
return ImageBlock;
|
||||||
default:
|
default:
|
||||||
return UnSupportBlock;
|
return UnSupportBlock;
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,8 @@ import KeyboardNavigation, {
|
|||||||
KeyboardNavigationOption,
|
KeyboardNavigationOption,
|
||||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
||||||
import { openUrl } from '$app/utils/open_url';
|
import { openUrl, pattern } from '$app/utils/open_url';
|
||||||
|
|
||||||
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
|
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { TextField } from '@mui/material';
|
import { TextField } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { pattern } from '$app/utils/open_url';
|
||||||
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
|
||||||
|
|
||||||
function LinkEditInput({
|
function LinkEditInput({
|
||||||
link,
|
link,
|
||||||
|
@ -12,7 +12,7 @@ import KeyboardNavigation, {
|
|||||||
KeyboardNavigationOption,
|
KeyboardNavigationOption,
|
||||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
import { Color } from '$app/components/editor/components/tools/block_actions/color';
|
import { Color } from '$app/components/editor/components/tools/block_actions/color';
|
||||||
import { getModifier } from '$app/utils/get_modifier';
|
import { getModifier } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { EditorNodeType } from '$app/application/document/document.types';
|
import { EditorNodeType } from '$app/application/document/document.types';
|
||||||
|
@ -14,14 +14,18 @@ import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg';
|
|||||||
import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg';
|
import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg';
|
||||||
import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg';
|
import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg';
|
||||||
import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
|
import { ReactComponent as GridIcon } from '$app/assets/grid.svg';
|
||||||
|
import { ReactComponent as ImageIcon } from '$app/assets/image.svg';
|
||||||
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
|
import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { randomEmoji } from '$app/utils/emoji';
|
import { randomEmoji } from '$app/utils/emoji';
|
||||||
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||||
import { YjsEditor } from '@slate-yjs/core';
|
import { YjsEditor } from '@slate-yjs/core';
|
||||||
|
import { useEditorBlockDispatch } from '$app/components/editor/stores/block';
|
||||||
|
|
||||||
enum SlashCommandPanelTab {
|
enum SlashCommandPanelTab {
|
||||||
BASIC = 'basic',
|
BASIC = 'basic',
|
||||||
|
MEDIA = 'media',
|
||||||
|
DATABASE = 'database',
|
||||||
ADVANCED = 'advanced',
|
ADVANCED = 'advanced',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +44,7 @@ export enum SlashOptionType {
|
|||||||
Code,
|
Code,
|
||||||
Grid,
|
Grid,
|
||||||
MathEquation,
|
MathEquation,
|
||||||
|
Image,
|
||||||
}
|
}
|
||||||
const slashOptionGroup = [
|
const slashOptionGroup = [
|
||||||
{
|
{
|
||||||
@ -55,11 +60,20 @@ const slashOptionGroup = [
|
|||||||
SlashOptionType.Quote,
|
SlashOptionType.Quote,
|
||||||
SlashOptionType.ToggleList,
|
SlashOptionType.ToggleList,
|
||||||
SlashOptionType.Divider,
|
SlashOptionType.Divider,
|
||||||
|
SlashOptionType.Callout,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SlashCommandPanelTab.MEDIA,
|
||||||
|
options: [SlashOptionType.Code, SlashOptionType.Image],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SlashCommandPanelTab.DATABASE,
|
||||||
|
options: [SlashOptionType.Grid],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SlashCommandPanelTab.ADVANCED,
|
key: SlashCommandPanelTab.ADVANCED,
|
||||||
options: [SlashOptionType.Callout, SlashOptionType.Code, SlashOptionType.Grid, SlashOptionType.MathEquation],
|
options: [SlashOptionType.MathEquation],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -78,6 +92,7 @@ const slashOptionMapToEditorNodeType = {
|
|||||||
[SlashOptionType.Code]: EditorNodeType.CodeBlock,
|
[SlashOptionType.Code]: EditorNodeType.CodeBlock,
|
||||||
[SlashOptionType.Grid]: EditorNodeType.GridBlock,
|
[SlashOptionType.Grid]: EditorNodeType.GridBlock,
|
||||||
[SlashOptionType.MathEquation]: EditorNodeType.EquationBlock,
|
[SlashOptionType.MathEquation]: EditorNodeType.EquationBlock,
|
||||||
|
[SlashOptionType.Image]: EditorNodeType.ImageBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingTypeToLevelMap: Record<string, number> = {
|
const headingTypeToLevelMap: Record<string, number> = {
|
||||||
@ -95,6 +110,7 @@ export function useSlashCommandPanel({
|
|||||||
searchText: string;
|
searchText: string;
|
||||||
closePanel: (deleteText?: boolean) => void;
|
closePanel: (deleteText?: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { openPopover } = useEditorBlockDispatch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlate();
|
const editor = useSlate();
|
||||||
const onConfirm = useCallback(
|
const onConfirm = useCallback(
|
||||||
@ -127,6 +143,12 @@ export function useSlashCommandPanel({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeType === EditorNodeType.ImageBlock) {
|
||||||
|
Object.assign(data, {
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
closePanel(true);
|
closePanel(true);
|
||||||
|
|
||||||
const newNode = getBlock(editor);
|
const newNode = getBlock(editor);
|
||||||
@ -145,12 +167,20 @@ export function useSlashCommandPanel({
|
|||||||
editor.select(nextPath);
|
editor.select(nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomEditor.turnToBlock(editor, {
|
const turnIntoBlock = CustomEditor.turnToBlock(editor, {
|
||||||
type: nodeType,
|
type: nodeType,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (turnIntoBlock && turnIntoBlock.blockId) {
|
||||||
|
if (turnIntoBlock.type === EditorNodeType.ImageBlock || turnIntoBlock.type === EditorNodeType.EquationBlock) {
|
||||||
|
openPopover(turnIntoBlock.type, turnIntoBlock.blockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
},
|
},
|
||||||
[editor, closePanel]
|
[editor, closePanel, openPopover]
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeToLabelIconMap = useMemo(() => {
|
const typeToLabelIconMap = useMemo(() => {
|
||||||
@ -212,6 +242,10 @@ export function useSlashCommandPanel({
|
|||||||
label: t('document.plugins.mathEquation.name'),
|
label: t('document.plugins.mathEquation.name'),
|
||||||
Icon: FunctionsOutlined,
|
Icon: FunctionsOutlined,
|
||||||
},
|
},
|
||||||
|
[SlashOptionType.Image]: {
|
||||||
|
label: t('editor.image'),
|
||||||
|
Icon: ImageIcon,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@ -219,6 +253,8 @@ export function useSlashCommandPanel({
|
|||||||
return {
|
return {
|
||||||
[SlashCommandPanelTab.BASIC]: 'Basic',
|
[SlashCommandPanelTab.BASIC]: 'Basic',
|
||||||
[SlashCommandPanelTab.ADVANCED]: 'Advanced',
|
[SlashCommandPanelTab.ADVANCED]: 'Advanced',
|
||||||
|
[SlashCommandPanelTab.MEDIA]: 'Media',
|
||||||
|
[SlashCommandPanelTab.DATABASE]: 'Database',
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -22,12 +22,15 @@ function SelectionActions({
|
|||||||
isAcrossBlocks,
|
isAcrossBlocks,
|
||||||
storeSelection,
|
storeSelection,
|
||||||
restoreSelection,
|
restoreSelection,
|
||||||
|
isIncludeRoot,
|
||||||
}: {
|
}: {
|
||||||
storeSelection: () => void;
|
storeSelection: () => void;
|
||||||
restoreSelection: () => void;
|
restoreSelection: () => void;
|
||||||
isAcrossBlocks: boolean;
|
isAcrossBlocks: boolean;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
isIncludeRoot: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (isIncludeRoot) return null;
|
||||||
return (
|
return (
|
||||||
<div className={'flex w-fit flex-grow items-center gap-1'}>
|
<div className={'flex w-fit flex-grow items-center gap-1'}>
|
||||||
{!isAcrossBlocks && (
|
{!isAcrossBlocks && (
|
||||||
|
@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
|||||||
const [isAcrossBlocks, setIsAcrossBlocks] = useState(false);
|
const [isAcrossBlocks, setIsAcrossBlocks] = useState(false);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const isFocusedEditor = useFocused();
|
const isFocusedEditor = useFocused();
|
||||||
|
const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor);
|
||||||
|
|
||||||
// paint the selection when the editor is blurred
|
// paint the selection when the editor is blurred
|
||||||
const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch();
|
const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch();
|
||||||
@ -61,12 +62,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close toolbar when selection include root
|
|
||||||
if (CustomEditor.selectionIncludeRoot(editor)) {
|
|
||||||
closeToolbar();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const position = getSelectionPosition(editor);
|
const position = getSelectionPosition(editor);
|
||||||
|
|
||||||
if (!position) {
|
if (!position) {
|
||||||
@ -123,7 +118,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
|||||||
closeToolbar();
|
closeToolbar();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
|
if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -205,5 +200,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
|||||||
restoreSelection,
|
restoreSelection,
|
||||||
storeSelection,
|
storeSelection,
|
||||||
isAcrossBlocks,
|
isAcrossBlocks,
|
||||||
|
isIncludeRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError'
|
|||||||
const Toolbar = memo(() => {
|
const Toolbar = memo(() => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref);
|
const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -20,6 +20,7 @@ const Toolbar = memo(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectionActions
|
<SelectionActions
|
||||||
|
isIncludeRoot={isIncludeRoot}
|
||||||
isAcrossBlocks={isAcrossBlocks}
|
isAcrossBlocks={isAcrossBlocks}
|
||||||
storeSelection={storeSelection}
|
storeSelection={storeSelection}
|
||||||
restoreSelection={restoreSelection}
|
restoreSelection={restoreSelection}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.svg';
|
import { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.svg';
|
||||||
import { ReactComponent as AlignCenterSvg } from '$app/assets/align-center.svg';
|
import { ReactComponent as AlignCenterSvg } from '$app/assets/align-center.svg';
|
||||||
@ -6,15 +6,16 @@ import { ReactComponent as AlignRightSvg } from '$app/assets/align-right.svg';
|
|||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { IconButton } from '@mui/material';
|
import { IconButton } from '@mui/material';
|
||||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||||
|
import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function Align() {
|
export function Align() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const align = CustomEditor.getAlign(editor);
|
const align = CustomEditor.getAlign(editor);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@ -60,6 +61,36 @@ export function Align() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.ALIGN_LEFT)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleAlign(editor, 'left');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createHotkey(HOT_KEY_NAME.ALIGN_CENTER)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleAlign(editor, 'center');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createHotkey(HOT_KEY_NAME.ALIGN_RIGHT)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleAlign(editor, 'right');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement={'bottom'}
|
placement={'bottom'}
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
|
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
|
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function Bold() {
|
export function Bold() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Bold);
|
||||||
|
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Bold).modifier, []);
|
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.BOLD), []);
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
key: EditorMarkFormat.Bold,
|
key: EditorMarkFormat.Bold,
|
||||||
@ -20,6 +20,26 @@ export function Bold() {
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.BOLD)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleMark(editor, {
|
||||||
|
key: EditorMarkFormat.Bold,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -4,12 +4,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
|
import { ReactComponent as LinkSvg } from '$app/assets/link.svg';
|
||||||
import { Editor } from 'slate';
|
import { Editor, Range } from 'slate';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores';
|
import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores';
|
||||||
import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link';
|
import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { getModifier } from '$app/utils/get_modifier';
|
import { getModifier } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function Href() {
|
export function Href() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -69,6 +69,7 @@ export function Href() {
|
|||||||
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
const handleShortcut = (e: KeyboardEvent) => {
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
if (isHotkey('mod+k', e)) {
|
if (isHotkey('mod+k', e)) {
|
||||||
|
if (editor.selection && Range.isCollapsed(editor.selection)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
onClick();
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
|
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
|
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function InlineCode() {
|
export function InlineCode() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Code);
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Code).modifier, []);
|
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.CODE), []);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
@ -20,6 +20,26 @@ export function InlineCode() {
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.CODE)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleMark(editor, {
|
||||||
|
key: EditorMarkFormat.Code,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
|
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
|
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function Italic() {
|
export function Italic() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Italic);
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Italic).modifier, []);
|
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.ITALIC), []);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
@ -20,6 +20,25 @@ export function Italic() {
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.ITALIC)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleMark(editor, {
|
||||||
|
key: EditorMarkFormat.Italic,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
|
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
|
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function StrikeThrough() {
|
export function StrikeThrough() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.StrikeThrough);
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.StrikeThrough).modifier, []);
|
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.STRIKETHROUGH), []);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
@ -20,6 +20,26 @@ export function StrikeThrough() {
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.STRIKETHROUGH)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleMark(editor, {
|
||||||
|
key: EditorMarkFormat.StrikeThrough,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSlateStatic } from 'slate-react';
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
|
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
|
||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
import { EditorMarkFormat } from '$app/application/document/document.types';
|
||||||
import { getHotKey } from '$app/components/editor/plugins/shortcuts';
|
import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||||
|
|
||||||
export function Underline() {
|
export function Underline() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const editor = useSlateStatic();
|
const editor = useSlateStatic();
|
||||||
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline);
|
const isActivated = CustomEditor.isMarkActive(editor, EditorMarkFormat.Underline);
|
||||||
const modifier = useMemo(() => getHotKey(EditorMarkFormat.Underline).modifier, []);
|
const modifier = useMemo(() => createHotKeyLabel(HOT_KEY_NAME.UNDERLINE), []);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const onClick = useCallback(() => {
|
||||||
CustomEditor.toggleMark(editor, {
|
CustomEditor.toggleMark(editor, {
|
||||||
@ -20,6 +20,26 @@ export function Underline() {
|
|||||||
});
|
});
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||||
|
const handleShortcut = (e: KeyboardEvent) => {
|
||||||
|
if (createHotkey(HOT_KEY_NAME.UNDERLINE)(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
CustomEditor.toggleMark(editor, {
|
||||||
|
key: EditorMarkFormat.Underline,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
editorDom.addEventListener('keydown', handleShortcut);
|
||||||
|
return () => {
|
||||||
|
editorDom.removeEventListener('keydown', handleShortcut);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -136,4 +136,23 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
|||||||
.grid-block .grid-scroll-container::-webkit-scrollbar {
|
.grid-block .grid-scroll-container::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-resizer {
|
||||||
|
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
|
||||||
|
.resize-handle {
|
||||||
|
@apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0;
|
||||||
|
background: var(--fill-toolbar);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
.resize-handle {
|
||||||
|
@apply opacity-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-block, .math-equation-block {
|
||||||
|
::selection {
|
||||||
|
@apply bg-transparent;
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,72 +0,0 @@
|
|||||||
import { EditorMarkFormat } from '$app/application/document/document.types';
|
|
||||||
import { getModifier } from '$app/utils/get_modifier';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hotkeys shortcuts
|
|
||||||
* @description
|
|
||||||
* - bold: Mod+b
|
|
||||||
* - italic: Mod+i
|
|
||||||
* - underline: Mod+u
|
|
||||||
* - strikethrough: Mod+Shift+s
|
|
||||||
* - code: Mod+Shift+c
|
|
||||||
*/
|
|
||||||
export const getHotKeys: () => {
|
|
||||||
[key: string]: { modifier: string; hotkey: string; markKey: EditorMarkFormat; markValue: string | boolean };
|
|
||||||
} = () => {
|
|
||||||
const modifier = getModifier();
|
|
||||||
|
|
||||||
return {
|
|
||||||
[EditorMarkFormat.Bold]: {
|
|
||||||
hotkey: 'mod+b',
|
|
||||||
modifier: `${modifier} + B`,
|
|
||||||
markKey: EditorMarkFormat.Bold,
|
|
||||||
markValue: true,
|
|
||||||
},
|
|
||||||
[EditorMarkFormat.Italic]: {
|
|
||||||
hotkey: 'mod+i',
|
|
||||||
modifier: `${modifier} + I`,
|
|
||||||
markKey: EditorMarkFormat.Italic,
|
|
||||||
markValue: true,
|
|
||||||
},
|
|
||||||
[EditorMarkFormat.Underline]: {
|
|
||||||
hotkey: 'mod+u',
|
|
||||||
modifier: `${modifier} + U`,
|
|
||||||
markKey: EditorMarkFormat.Underline,
|
|
||||||
markValue: true,
|
|
||||||
},
|
|
||||||
[EditorMarkFormat.StrikeThrough]: {
|
|
||||||
hotkey: 'mod+shift+s',
|
|
||||||
modifier: `${modifier} + Shift + S`,
|
|
||||||
markKey: EditorMarkFormat.StrikeThrough,
|
|
||||||
markValue: true,
|
|
||||||
},
|
|
||||||
[EditorMarkFormat.Code]: {
|
|
||||||
hotkey: 'mod+shift+c',
|
|
||||||
modifier: `${modifier} + Shift + C`,
|
|
||||||
markKey: EditorMarkFormat.Code,
|
|
||||||
markValue: true,
|
|
||||||
},
|
|
||||||
'align-left': {
|
|
||||||
hotkey: 'control+shift+l',
|
|
||||||
modifier: `Ctrl + Shift + L`,
|
|
||||||
markKey: EditorMarkFormat.Align,
|
|
||||||
markValue: 'left',
|
|
||||||
},
|
|
||||||
'align-center': {
|
|
||||||
hotkey: 'control+shift+e',
|
|
||||||
modifier: `Ctrl + Shift + E`,
|
|
||||||
markKey: EditorMarkFormat.Align,
|
|
||||||
markValue: 'center',
|
|
||||||
},
|
|
||||||
'align-right': {
|
|
||||||
hotkey: 'control+shift+r',
|
|
||||||
modifier: `Ctrl + Shift + R`,
|
|
||||||
markKey: EditorMarkFormat.Align,
|
|
||||||
markValue: 'right',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getHotKey = (key: EditorMarkFormat) => {
|
|
||||||
return getHotKeys()[key];
|
|
||||||
};
|
|
@ -1,3 +1,2 @@
|
|||||||
export * from './shortcuts.hooks';
|
export * from './shortcuts.hooks';
|
||||||
export * from './withShortcuts';
|
export * from './withShortcuts';
|
||||||
export * from './hotkey';
|
|
||||||
|
@ -1,28 +1,14 @@
|
|||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { useCallback, KeyboardEvent } from 'react';
|
import { useCallback, KeyboardEvent } from 'react';
|
||||||
import {
|
import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types';
|
||||||
EditorMarkFormat,
|
|
||||||
EditorNodeType,
|
|
||||||
TodoListNode,
|
|
||||||
ToggleListNode,
|
|
||||||
} from '$app/application/document/document.types';
|
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||||
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
|
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
|
||||||
import { CustomEditor } from '$app/components/editor/command';
|
import { CustomEditor } from '$app/components/editor/command';
|
||||||
import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hotkeys shortcuts
|
* Hotkeys shortcuts
|
||||||
* @description [getHotKeys] is defined in [hotkey.ts]
|
* @description [getHotKeys] is defined in [hotkey.ts]
|
||||||
* - bold: Mod+b
|
|
||||||
* - italic: Mod+i
|
|
||||||
* - underline: Mod+u
|
|
||||||
* - strikethrough: Mod+Shift+s
|
|
||||||
* - code: Mod+Shift+c
|
|
||||||
* - align left: Mod+Shift+l
|
|
||||||
* - align center: Mod+Shift+e
|
|
||||||
* - align right: Mod+Shift+r
|
|
||||||
* - indent: Tab
|
* - indent: Tab
|
||||||
* - outdent: Shift+Tab
|
* - outdent: Shift+Tab
|
||||||
* - split block: Enter
|
* - split block: Enter
|
||||||
@ -33,24 +19,6 @@ import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
|
|||||||
export function useShortcuts(editor: ReactEditor) {
|
export function useShortcuts(editor: ReactEditor) {
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
Object.entries(getHotKeys()).forEach(([_, item]) => {
|
|
||||||
if (isHotkey(item.hotkey, e)) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
if (CustomEditor.selectionIncludeRoot(editor)) return;
|
|
||||||
if (item.markKey === EditorMarkFormat.Align) {
|
|
||||||
CustomEditor.toggleAlign(editor, item.markValue as string);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CustomEditor.toggleMark(editor, {
|
|
||||||
key: item.markKey,
|
|
||||||
value: item.markValue,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = getBlock(editor);
|
const node = getBlock(editor);
|
||||||
|
|
||||||
if (isHotkey('Escape', e)) {
|
if (isHotkey('Escape', e)) {
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo } from 'react';
|
||||||
|
import { proxy, useSnapshot } from 'valtio';
|
||||||
|
import { EditorNodeType } from '$app/application/document/document.types';
|
||||||
|
|
||||||
|
export interface EditorBlockState {
|
||||||
|
[EditorNodeType.ImageBlock]: {
|
||||||
|
popoverOpen: boolean;
|
||||||
|
blockId?: string;
|
||||||
|
};
|
||||||
|
[EditorNodeType.EquationBlock]: {
|
||||||
|
popoverOpen: boolean;
|
||||||
|
blockId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[EditorNodeType.ImageBlock]: {
|
||||||
|
popoverOpen: false,
|
||||||
|
blockId: undefined,
|
||||||
|
},
|
||||||
|
[EditorNodeType.EquationBlock]: {
|
||||||
|
popoverOpen: false,
|
||||||
|
blockId: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorBlockStateContext = createContext<EditorBlockState>(initialState);
|
||||||
|
|
||||||
|
export const EditorBlockStateProvider = EditorBlockStateContext.Provider;
|
||||||
|
|
||||||
|
export function useEditorInitialBlockState() {
|
||||||
|
const state = useMemo(() => {
|
||||||
|
return proxy({
|
||||||
|
...initialState,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorBlockState(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) {
|
||||||
|
const context = useContext(EditorBlockStateContext);
|
||||||
|
|
||||||
|
return useSnapshot(context[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorBlockDispatch() {
|
||||||
|
const context = useContext(EditorBlockStateContext);
|
||||||
|
|
||||||
|
const openPopover = useCallback(
|
||||||
|
(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock, blockId: string) => {
|
||||||
|
context[key].popoverOpen = true;
|
||||||
|
context[key].blockId = blockId;
|
||||||
|
},
|
||||||
|
[context]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closePopover = useCallback(
|
||||||
|
(key: EditorNodeType.ImageBlock | EditorNodeType.EquationBlock) => {
|
||||||
|
context[key].popoverOpen = false;
|
||||||
|
context[key].blockId = undefined;
|
||||||
|
},
|
||||||
|
[context]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openPopover,
|
||||||
|
closePopover,
|
||||||
|
};
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { useInitialDecorateState } from '$app/components/editor/stores/decorate'
|
|||||||
import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected';
|
import { useInitialSelectedBlocks } from '$app/components/editor/stores/selected';
|
||||||
import { useInitialSlashState } from '$app/components/editor/stores/slash';
|
import { useInitialSlashState } from '$app/components/editor/stores/slash';
|
||||||
import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node';
|
import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node';
|
||||||
|
import { useEditorInitialBlockState } from '$app/components/editor/stores/block';
|
||||||
|
|
||||||
export * from './decorate';
|
export * from './decorate';
|
||||||
export * from './selected';
|
export * from './selected';
|
||||||
@ -14,6 +15,7 @@ export function useInitialEditorState(editor: ReactEditor) {
|
|||||||
const selectedBlocks = useInitialSelectedBlocks(editor);
|
const selectedBlocks = useInitialSelectedBlocks(editor);
|
||||||
const slashState = useInitialSlashState();
|
const slashState = useInitialSlashState();
|
||||||
const inlineBlockState = useInitialEditorInlineBlockState();
|
const inlineBlockState = useInitialEditorInlineBlockState();
|
||||||
|
const blockState = useEditorInitialBlockState();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedBlocks,
|
selectedBlocks,
|
||||||
@ -21,5 +23,6 @@ export function useInitialEditorState(editor: ReactEditor) {
|
|||||||
decorateState,
|
decorateState,
|
||||||
slashState,
|
slashState,
|
||||||
inlineBlockState,
|
inlineBlockState,
|
||||||
|
blockState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,8 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { proxySet, subscribeKey } from 'valtio/utils';
|
import { proxySet, subscribeKey } from 'valtio/utils';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Element } from 'slate';
|
import { Element } from 'slate';
|
||||||
|
|
||||||
export function useSelectedBlocksSize() {
|
|
||||||
const selectedBlocks = useContext(EditorSelectedBlockContext);
|
|
||||||
|
|
||||||
const [selectedLength, setSelectedLength] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
subscribeKey(selectedBlocks, 'size', (v) => setSelectedLength(v));
|
|
||||||
}, [selectedBlocks]);
|
|
||||||
|
|
||||||
return selectedLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInitialSelectedBlocks(editor: ReactEditor) {
|
export function useInitialSelectedBlocks(editor: ReactEditor) {
|
||||||
const selectedBlocks = useMemo(() => proxySet([]), []);
|
const selectedBlocks = useMemo(() => proxySet([]), []);
|
||||||
const [selectedLength, setSelectedLength] = useState(0);
|
const [selectedLength, setSelectedLength] = useState(0);
|
||||||
|
@ -36,7 +36,7 @@ function Layout({ children }: { children: ReactNode }) {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100vh - 64px - 48px)',
|
height: 'calc(100vh - 64px)',
|
||||||
}}
|
}}
|
||||||
className={'appflowy-layout appflowy-scroll-container select-none overflow-y-auto overflow-x-hidden'}
|
className={'appflowy-layout appflowy-scroll-container select-none overflow-y-auto overflow-x-hidden'}
|
||||||
>
|
>
|
||||||
|
@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
|||||||
import { sidebarActions } from '$app_reducers/sidebar/slice';
|
import { sidebarActions } from '$app_reducers/sidebar/slice';
|
||||||
import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg';
|
import { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getModifier } from '$app/utils/get_modifier';
|
import { getModifier } from '$app/utils/hotkeys';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
|
|
||||||
function CollapseMenuButton() {
|
function CollapseMenuButton() {
|
||||||
|
@ -10,7 +10,7 @@ import RenameDialog from '../../_shared/confirm_dialog/RenameDialog';
|
|||||||
import { Page } from '$app_reducers/pages/slice';
|
import { Page } from '$app_reducers/pages/slice';
|
||||||
import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog';
|
import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog';
|
||||||
import OperationMenu from '$app/components/layout/nested_page/OperationMenu';
|
import OperationMenu from '$app/components/layout/nested_page/OperationMenu';
|
||||||
import { getModifier } from '$app/utils/get_modifier';
|
import { getModifier } from '$app/utils/hotkeys';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
|
|
||||||
function MoreButton({
|
function MoreButton({
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
export const isMac = () => {
|
|
||||||
return navigator.userAgent.includes('Mac OS X');
|
|
||||||
};
|
|
||||||
|
|
||||||
const MODIFIERS = {
|
|
||||||
control: 'Ctrl',
|
|
||||||
meta: '⌘',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getModifier = () => {
|
|
||||||
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
|
|
||||||
};
|
|
61
frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts
Normal file
61
frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import isHotkey from 'is-hotkey';
|
||||||
|
|
||||||
|
export const isMac = () => {
|
||||||
|
return navigator.userAgent.includes('Mac OS X');
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODIFIERS = {
|
||||||
|
control: 'Ctrl',
|
||||||
|
meta: '⌘',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModifier = () => {
|
||||||
|
return isMac() ? MODIFIERS.meta : MODIFIERS.control;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum HOT_KEY_NAME {
|
||||||
|
ALIGN_LEFT = 'align-left',
|
||||||
|
ALIGN_CENTER = 'align-center',
|
||||||
|
ALIGN_RIGHT = 'align-right',
|
||||||
|
BOLD = 'bold',
|
||||||
|
ITALIC = 'italic',
|
||||||
|
UNDERLINE = 'underline',
|
||||||
|
STRIKETHROUGH = 'strikethrough',
|
||||||
|
CODE = 'code',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHotKeys = {
|
||||||
|
[HOT_KEY_NAME.ALIGN_LEFT]: 'control+shift+l',
|
||||||
|
[HOT_KEY_NAME.ALIGN_CENTER]: 'control+shift+e',
|
||||||
|
[HOT_KEY_NAME.ALIGN_RIGHT]: 'control+shift+r',
|
||||||
|
[HOT_KEY_NAME.BOLD]: 'mod+b',
|
||||||
|
[HOT_KEY_NAME.ITALIC]: 'mod+i',
|
||||||
|
[HOT_KEY_NAME.UNDERLINE]: 'mod+u',
|
||||||
|
[HOT_KEY_NAME.STRIKETHROUGH]: 'mod+shift+s',
|
||||||
|
[HOT_KEY_NAME.CODE]: 'mod+shift+c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceModifier = (hotkey: string) => {
|
||||||
|
return hotkey.replace('mod', getModifier()).replace('control', 'ctrl');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
|
||||||
|
const keys = customHotKeys || defaultHotKeys;
|
||||||
|
const hotkey = keys[hotkeyName];
|
||||||
|
|
||||||
|
return (event: KeyboardEvent) => {
|
||||||
|
return isHotkey(hotkey, event);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record<HOT_KEY_NAME, string>) => {
|
||||||
|
const keys = customHotKeys || defaultHotKeys;
|
||||||
|
const hotkey = replaceModifier(keys[hotkeyName]);
|
||||||
|
|
||||||
|
return hotkey
|
||||||
|
.split('+')
|
||||||
|
.map((key) => {
|
||||||
|
return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1);
|
||||||
|
})
|
||||||
|
.join(' + ');
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import { open as openWindow } from '@tauri-apps/api/shell';
|
import { open as openWindow } from '@tauri-apps/api/shell';
|
||||||
|
|
||||||
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/;
|
||||||
|
|
||||||
export function openUrl(str: string) {
|
export function openUrl(str: string) {
|
||||||
if (pattern.test(str)) {
|
if (pattern.test(str)) {
|
||||||
|
@ -245,7 +245,9 @@
|
|||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"dontRemove": "Don't remove"
|
"dontRemove": "Don't remove",
|
||||||
|
"copyLink": "Copy Link",
|
||||||
|
"align": "Align"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"welcome": "Welcome!",
|
"welcome": "Welcome!",
|
||||||
@ -1161,7 +1163,8 @@
|
|||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"replaceAll": "Replace all",
|
"replaceAll": "Replace all",
|
||||||
"noResult": "No results",
|
"noResult": "No results",
|
||||||
"caseSensitive": "Case sensitive"
|
"caseSensitive": "Case sensitive",
|
||||||
|
"searchMore": "Search to find more results"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"weAreSorry": "We're sorry",
|
"weAreSorry": "We're sorry",
|
||||||
|
Loading…
Reference in New Issue
Block a user