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-react": "^0.101.3",
|
||||
"ts-results": "^3.3.0",
|
||||
"unsplash-js": "^7.0.19",
|
||||
"utf8": "^3.0.0",
|
||||
"valtio": "^1.12.1",
|
||||
"yjs": "^13.5.51"
|
||||
|
@ -166,6 +166,9 @@ dependencies:
|
||||
ts-results:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
unsplash-js:
|
||||
specifier: ^7.0.19
|
||||
version: 7.0.19
|
||||
utf8:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@ -6853,6 +6856,11 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
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):
|
||||
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
|
||||
hasBin: true
|
||||
|
@ -109,6 +109,22 @@ export interface MathEquationNode extends Element {
|
||||
} & 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 {
|
||||
type: EditorInlineNodeType.Formula;
|
||||
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;
|
||||
width: number;
|
||||
},
|
||||
horizontal: number | 'center' | 'left' | 'right'
|
||||
paperWidth: number,
|
||||
horizontal: number | 'center' | 'left' | 'right',
|
||||
transformHorizontal: number | 'center' | 'left' | 'right'
|
||||
) {
|
||||
let offset = 0;
|
||||
|
||||
@ -42,6 +44,12 @@ function getOffsetLeft(
|
||||
offset = rect.width;
|
||||
}
|
||||
|
||||
if (transformHorizontal === 'center') {
|
||||
offset -= paperWidth / 2;
|
||||
} else if (transformHorizontal === 'right') {
|
||||
offset -= paperWidth;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
@ -50,7 +58,9 @@ function getOffsetTop(
|
||||
height: number;
|
||||
width: number;
|
||||
},
|
||||
vertical: number | 'center' | 'bottom' | 'top'
|
||||
papertHeight: number,
|
||||
vertical: number | 'center' | 'bottom' | 'top',
|
||||
transformVertical: number | 'center' | 'bottom' | 'top'
|
||||
) {
|
||||
let offset = 0;
|
||||
|
||||
@ -62,6 +72,12 @@ function getOffsetTop(
|
||||
offset = rect.height;
|
||||
}
|
||||
|
||||
if (transformVertical === 'center') {
|
||||
offset -= papertHeight / 2;
|
||||
} else if (transformVertical === 'bottom') {
|
||||
offset -= papertHeight;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
@ -122,8 +138,12 @@ const usePopoverAutoPosition = ({
|
||||
};
|
||||
|
||||
// calculate new paper width
|
||||
const newLeft = anchorRect.left + getOffsetLeft(anchorRect, initialAnchorOrigin.horizontal);
|
||||
const newTop = anchorRect.top + getOffsetTop(anchorRect, initialAnchorOrigin.vertical);
|
||||
const newLeft =
|
||||
anchorRect.left +
|
||||
getOffsetLeft(anchorRect, newPaperWidth, initialAnchorOrigin.horizontal, initialTransformOrigin.horizontal);
|
||||
const newTop =
|
||||
anchorRect.top +
|
||||
getOffsetTop(anchorRect, newPaperHeight, initialAnchorOrigin.vertical, initialTransformOrigin.vertical);
|
||||
|
||||
let isExceedViewportRight = false;
|
||||
let isExceedViewportBottom = false;
|
||||
|
@ -30,6 +30,7 @@ import {
|
||||
ToggleListNode,
|
||||
inlineNodeTypes,
|
||||
FormulaNode,
|
||||
ImageNode,
|
||||
} from '$app/application/document/document.types';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { generateId } from '$app/components/editor/provider/utils/convert';
|
||||
@ -39,6 +40,7 @@ export const EmbedTypes: string[] = [
|
||||
EditorNodeType.DividerBlock,
|
||||
EditorNodeType.EquationBlock,
|
||||
EditorNodeType.GridBlock,
|
||||
EditorNodeType.ImageBlock,
|
||||
];
|
||||
|
||||
export const CustomEditor = {
|
||||
@ -120,7 +122,7 @@ export const CustomEditor = {
|
||||
at: path,
|
||||
});
|
||||
Transforms.insertNodes(editor, cloneNode, { at: path });
|
||||
return;
|
||||
return cloneNode;
|
||||
}
|
||||
|
||||
const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType);
|
||||
@ -148,6 +150,8 @@ export const CustomEditor = {
|
||||
if (selection) {
|
||||
editor.select(selection);
|
||||
}
|
||||
|
||||
return cloneNode;
|
||||
},
|
||||
tabForward,
|
||||
tabBackward,
|
||||
@ -346,6 +350,19 @@ export const CustomEditor = {
|
||||
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 {
|
||||
const cloneNode: Element = {
|
||||
...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 { EditorElementProps, MathEquationNode } from '$app/application/document/document.types';
|
||||
import { forwardRef, memo, useEffect, useRef } from 'react';
|
||||
import { EditorElementProps, EditorNodeType, MathEquationNode } from '$app/application/document/document.types';
|
||||
import KatexMath from '$app/components/_shared/katex_math/KatexMath';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FunctionsOutlined } from '@mui/icons-material';
|
||||
import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover';
|
||||
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';
|
||||
import { useEditorBlockDispatch, useEditorBlockState } from '$app/components/editor/stores/block';
|
||||
|
||||
export const MathEquation = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>(
|
||||
@ -12,7 +13,9 @@ export const MathEquation = memo(
|
||||
const formula = node.data.formula;
|
||||
const { t } = useTranslation();
|
||||
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();
|
||||
|
||||
@ -26,7 +29,7 @@ export const MathEquation = memo(
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
openPopover(EditorNodeType.EquationBlock, node.blockId);
|
||||
}
|
||||
};
|
||||
|
||||
@ -37,7 +40,7 @@ export const MathEquation = memo(
|
||||
return () => {
|
||||
slateDom.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [editor, selected]);
|
||||
}, [editor, node.blockId, openPopover, selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -45,9 +48,9 @@ export const MathEquation = memo(
|
||||
{...attributes}
|
||||
ref={containerRef}
|
||||
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
|
||||
contentEditable={false}
|
||||
@ -71,7 +74,7 @@ export const MathEquation = memo(
|
||||
{open && (
|
||||
<EditPopover
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
closePopover(EditorNodeType.EquationBlock);
|
||||
}}
|
||||
node={node}
|
||||
open={open}
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
EditorInlineBlockStateProvider,
|
||||
} from '$app/components/editor/stores';
|
||||
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 }) {
|
||||
const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType);
|
||||
@ -33,6 +34,7 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
|
||||
decorateState,
|
||||
slashState,
|
||||
inlineBlockState,
|
||||
blockState,
|
||||
} = useInitialEditorState(editor);
|
||||
|
||||
const decorate = useCallback(
|
||||
@ -60,24 +62,26 @@ function Editor({ sharedType, disableFocus }: { sharedType: Y.XmlText; id: strin
|
||||
return (
|
||||
<EditorSelectedBlockProvider value={selectedBlocks}>
|
||||
<DecorateStateProvider value={decorateState}>
|
||||
<EditorInlineBlockStateProvider value={inlineBlockState}>
|
||||
<SlashStateProvider value={slashState}>
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<BlockActionsToolbar />
|
||||
<SelectionToolbar />
|
||||
<EditorBlockStateProvider value={blockState}>
|
||||
<EditorInlineBlockStateProvider value={inlineBlockState}>
|
||||
<SlashStateProvider value={slashState}>
|
||||
<Slate editor={editor} initialValue={initialValue}>
|
||||
<BlockActionsToolbar />
|
||||
<SelectionToolbar />
|
||||
|
||||
<CustomEditable
|
||||
{...props}
|
||||
disableFocus={disableFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
decorate={decorate}
|
||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||
/>
|
||||
<CommandPanel />
|
||||
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
||||
</Slate>
|
||||
</SlashStateProvider>
|
||||
</EditorInlineBlockStateProvider>
|
||||
<CustomEditable
|
||||
{...props}
|
||||
disableFocus={disableFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
decorate={decorate}
|
||||
className={'px-16 caret-text-title outline-none focus:outline-none'}
|
||||
/>
|
||||
<CommandPanel />
|
||||
<div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} />
|
||||
</Slate>
|
||||
</SlashStateProvider>
|
||||
</EditorInlineBlockStateProvider>
|
||||
</EditorBlockStateProvider>
|
||||
</DecorateStateProvider>
|
||||
</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 { GridBlock } from '$app/components/editor/components/blocks/database';
|
||||
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 { Page } from '../blocks/page';
|
||||
import { useElementState } from '$app/components/editor/components/editor/Element.hooks';
|
||||
@ -68,6 +70,8 @@ function Element({ element, attributes, children }: RenderElementProps) {
|
||||
return GridBlock;
|
||||
case EditorNodeType.EquationBlock:
|
||||
return MathEquation;
|
||||
case EditorNodeType.ImageBlock:
|
||||
return ImageBlock;
|
||||
default:
|
||||
return UnSupportBlock;
|
||||
}
|
||||
|
@ -13,8 +13,8 @@ import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
||||
import { openUrl } from '$app/utils/open_url';
|
||||
import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput';
|
||||
import { openUrl, pattern } from '$app/utils/open_url';
|
||||
|
||||
function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) {
|
||||
const editor = useSlateStatic();
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/;
|
||||
import { pattern } from '$app/utils/open_url';
|
||||
|
||||
function LinkEditInput({
|
||||
link,
|
||||
|
@ -12,7 +12,7 @@ import KeyboardNavigation, {
|
||||
KeyboardNavigationOption,
|
||||
} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
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 { 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 ToggleListIcon } from '$app/assets/show-menu.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 { CustomEditor } from '$app/components/editor/command';
|
||||
import { randomEmoji } from '$app/utils/emoji';
|
||||
import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation';
|
||||
import { YjsEditor } from '@slate-yjs/core';
|
||||
import { useEditorBlockDispatch } from '$app/components/editor/stores/block';
|
||||
|
||||
enum SlashCommandPanelTab {
|
||||
BASIC = 'basic',
|
||||
MEDIA = 'media',
|
||||
DATABASE = 'database',
|
||||
ADVANCED = 'advanced',
|
||||
}
|
||||
|
||||
@ -40,6 +44,7 @@ export enum SlashOptionType {
|
||||
Code,
|
||||
Grid,
|
||||
MathEquation,
|
||||
Image,
|
||||
}
|
||||
const slashOptionGroup = [
|
||||
{
|
||||
@ -55,11 +60,20 @@ const slashOptionGroup = [
|
||||
SlashOptionType.Quote,
|
||||
SlashOptionType.ToggleList,
|
||||
SlashOptionType.Divider,
|
||||
SlashOptionType.Callout,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SlashCommandPanelTab.MEDIA,
|
||||
options: [SlashOptionType.Code, SlashOptionType.Image],
|
||||
},
|
||||
{
|
||||
key: SlashCommandPanelTab.DATABASE,
|
||||
options: [SlashOptionType.Grid],
|
||||
},
|
||||
{
|
||||
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.Grid]: EditorNodeType.GridBlock,
|
||||
[SlashOptionType.MathEquation]: EditorNodeType.EquationBlock,
|
||||
[SlashOptionType.Image]: EditorNodeType.ImageBlock,
|
||||
};
|
||||
|
||||
const headingTypeToLevelMap: Record<string, number> = {
|
||||
@ -95,6 +110,7 @@ export function useSlashCommandPanel({
|
||||
searchText: string;
|
||||
closePanel: (deleteText?: boolean) => void;
|
||||
}) {
|
||||
const { openPopover } = useEditorBlockDispatch();
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlate();
|
||||
const onConfirm = useCallback(
|
||||
@ -127,6 +143,12 @@ export function useSlashCommandPanel({
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeType === EditorNodeType.ImageBlock) {
|
||||
Object.assign(data, {
|
||||
url: '',
|
||||
});
|
||||
}
|
||||
|
||||
closePanel(true);
|
||||
|
||||
const newNode = getBlock(editor);
|
||||
@ -145,12 +167,20 @@ export function useSlashCommandPanel({
|
||||
editor.select(nextPath);
|
||||
}
|
||||
|
||||
CustomEditor.turnToBlock(editor, {
|
||||
const turnIntoBlock = CustomEditor.turnToBlock(editor, {
|
||||
type: nodeType,
|
||||
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(() => {
|
||||
@ -212,6 +242,10 @@ export function useSlashCommandPanel({
|
||||
label: t('document.plugins.mathEquation.name'),
|
||||
Icon: FunctionsOutlined,
|
||||
},
|
||||
[SlashOptionType.Image]: {
|
||||
label: t('editor.image'),
|
||||
Icon: ImageIcon,
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
@ -219,6 +253,8 @@ export function useSlashCommandPanel({
|
||||
return {
|
||||
[SlashCommandPanelTab.BASIC]: 'Basic',
|
||||
[SlashCommandPanelTab.ADVANCED]: 'Advanced',
|
||||
[SlashCommandPanelTab.MEDIA]: 'Media',
|
||||
[SlashCommandPanelTab.DATABASE]: 'Database',
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -22,12 +22,15 @@ function SelectionActions({
|
||||
isAcrossBlocks,
|
||||
storeSelection,
|
||||
restoreSelection,
|
||||
isIncludeRoot,
|
||||
}: {
|
||||
storeSelection: () => void;
|
||||
restoreSelection: () => void;
|
||||
isAcrossBlocks: boolean;
|
||||
visible: boolean;
|
||||
isIncludeRoot: boolean;
|
||||
}) {
|
||||
if (isIncludeRoot) return null;
|
||||
return (
|
||||
<div className={'flex w-fit flex-grow items-center gap-1'}>
|
||||
{!isAcrossBlocks && (
|
||||
|
@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
const [isAcrossBlocks, setIsAcrossBlocks] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isFocusedEditor = useFocused();
|
||||
const isIncludeRoot = CustomEditor.selectionIncludeRoot(editor);
|
||||
|
||||
// paint the selection when the editor is blurred
|
||||
const { add: addDecorate, clear: clearDecorate, getStaticState } = useDecorateDispatch();
|
||||
@ -61,12 +62,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
return;
|
||||
}
|
||||
|
||||
// Close toolbar when selection include root
|
||||
if (CustomEditor.selectionIncludeRoot(editor)) {
|
||||
closeToolbar();
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getSelectionPosition(editor);
|
||||
|
||||
if (!position) {
|
||||
@ -123,7 +118,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
closeToolbar();
|
||||
};
|
||||
|
||||
if (!isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
|
||||
if (isIncludeRoot || !isFocusedEditor || !selection || SlateRange.isCollapsed(selection)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
@ -205,5 +200,6 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
|
||||
restoreSelection,
|
||||
storeSelection,
|
||||
isAcrossBlocks,
|
||||
isIncludeRoot,
|
||||
};
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError'
|
||||
const Toolbar = memo(() => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref);
|
||||
const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -20,6 +20,7 @@ const Toolbar = memo(() => {
|
||||
}}
|
||||
>
|
||||
<SelectionActions
|
||||
isIncludeRoot={isIncludeRoot}
|
||||
isAcrossBlocks={isAcrossBlocks}
|
||||
storeSelection={storeSelection}
|
||||
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 { ReactComponent as AlignLeftSvg } from '$app/assets/align-left.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 { useTranslation } from 'react-i18next';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { IconButton } from '@mui/material';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
import { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys';
|
||||
|
||||
export function Align() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
const align = CustomEditor.getAlign(editor);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
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 (
|
||||
<Tooltip
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as BoldSvg } from '$app/assets/bold.svg';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
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(() => {
|
||||
CustomEditor.toggleMark(editor, {
|
||||
key: EditorMarkFormat.Bold,
|
||||
@ -20,6 +20,26 @@ export function Bold() {
|
||||
});
|
||||
}, [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 (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
|
@ -4,12 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
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 { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores';
|
||||
import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { getModifier } from '$app/utils/get_modifier';
|
||||
import { getModifier } from '$app/utils/hotkeys';
|
||||
|
||||
export function Href() {
|
||||
const { t } = useTranslation();
|
||||
@ -69,6 +69,7 @@ export function Href() {
|
||||
const editorDom = ReactEditor.toDOMNode(editor, editor);
|
||||
const handleShortcut = (e: KeyboardEvent) => {
|
||||
if (isHotkey('mod+k', e)) {
|
||||
if (editor.selection && Range.isCollapsed(editor.selection)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
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(() => {
|
||||
CustomEditor.toggleMark(editor, {
|
||||
@ -20,6 +20,26 @@ export function InlineCode() {
|
||||
});
|
||||
}, [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 (
|
||||
<ActionButton
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
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(() => {
|
||||
CustomEditor.toggleMark(editor, {
|
||||
@ -20,6 +20,25 @@ export function Italic() {
|
||||
});
|
||||
}, [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 (
|
||||
<ActionButton
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
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(() => {
|
||||
CustomEditor.toggleMark(editor, {
|
||||
@ -20,6 +20,26 @@ export function StrikeThrough() {
|
||||
});
|
||||
}, [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 (
|
||||
<ActionButton
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSlateStatic } from 'slate-react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg';
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const editor = useSlateStatic();
|
||||
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(() => {
|
||||
CustomEditor.toggleMark(editor, {
|
||||
@ -20,6 +20,26 @@ export function Underline() {
|
||||
});
|
||||
}, [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 (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
|
@ -136,4 +136,23 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
.grid-block .grid-scroll-container::-webkit-scrollbar {
|
||||
width: 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 './withShortcuts';
|
||||
export * from './hotkey';
|
||||
|
@ -1,28 +1,14 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { useCallback, KeyboardEvent } from 'react';
|
||||
import {
|
||||
EditorMarkFormat,
|
||||
EditorNodeType,
|
||||
TodoListNode,
|
||||
ToggleListNode,
|
||||
} from '$app/application/document/document.types';
|
||||
import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { getBlock } from '$app/components/editor/plugins/utils';
|
||||
import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants';
|
||||
import { CustomEditor } from '$app/components/editor/command';
|
||||
import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
|
||||
|
||||
/**
|
||||
* Hotkeys shortcuts
|
||||
* @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
|
||||
* - outdent: Shift+Tab
|
||||
* - split block: Enter
|
||||
@ -33,24 +19,6 @@ import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey';
|
||||
export function useShortcuts(editor: ReactEditor) {
|
||||
const onKeyDown = useCallback(
|
||||
(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);
|
||||
|
||||
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 { useInitialSlashState } from '$app/components/editor/stores/slash';
|
||||
import { useInitialEditorInlineBlockState } from '$app/components/editor/stores/inline_node';
|
||||
import { useEditorInitialBlockState } from '$app/components/editor/stores/block';
|
||||
|
||||
export * from './decorate';
|
||||
export * from './selected';
|
||||
@ -14,6 +15,7 @@ export function useInitialEditorState(editor: ReactEditor) {
|
||||
const selectedBlocks = useInitialSelectedBlocks(editor);
|
||||
const slashState = useInitialSlashState();
|
||||
const inlineBlockState = useInitialEditorInlineBlockState();
|
||||
const blockState = useEditorInitialBlockState();
|
||||
|
||||
return {
|
||||
selectedBlocks,
|
||||
@ -21,5 +23,6 @@ export function useInitialEditorState(editor: ReactEditor) {
|
||||
decorateState,
|
||||
slashState,
|
||||
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 { ReactEditor } from 'slate-react';
|
||||
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) {
|
||||
const selectedBlocks = useMemo(() => proxySet([]), []);
|
||||
const [selectedLength, setSelectedLength] = useState(0);
|
||||
|
@ -36,7 +36,7 @@ function Layout({ children }: { children: ReactNode }) {
|
||||
<TopBar />
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - 64px - 48px)',
|
||||
height: 'calc(100vh - 64px)',
|
||||
}}
|
||||
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 { ReactComponent as ShowMenuIcon } from '$app/assets/show-menu.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getModifier } from '$app/utils/get_modifier';
|
||||
import { getModifier } from '$app/utils/hotkeys';
|
||||
import isHotkey from 'is-hotkey';
|
||||
|
||||
function CollapseMenuButton() {
|
||||
|
@ -10,7 +10,7 @@ import RenameDialog from '../../_shared/confirm_dialog/RenameDialog';
|
||||
import { Page } from '$app_reducers/pages/slice';
|
||||
import DeleteDialog from '$app/components/layout/nested_page/DeleteDialog';
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
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) {
|
||||
if (pattern.test(str)) {
|
||||
|
@ -245,7 +245,9 @@
|
||||
"Cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"remove": "Remove",
|
||||
"dontRemove": "Don't remove"
|
||||
"dontRemove": "Don't remove",
|
||||
"copyLink": "Copy Link",
|
||||
"align": "Align"
|
||||
},
|
||||
"label": {
|
||||
"welcome": "Welcome!",
|
||||
@ -1161,7 +1163,8 @@
|
||||
"replace": "Replace",
|
||||
"replaceAll": "Replace all",
|
||||
"noResult": "No results",
|
||||
"caseSensitive": "Case sensitive"
|
||||
"caseSensitive": "Case sensitive",
|
||||
"searchMore": "Search to find more results"
|
||||
},
|
||||
"error": {
|
||||
"weAreSorry": "We're sorry",
|
||||
|
Loading…
Reference in New Issue
Block a user