feat: support image block (#4783)

This commit is contained in:
Kilu.He 2024-03-01 10:48:07 +08:00 committed by GitHub
parent 37bc5b3fbf
commit 835563f81b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1320 additions and 208 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import React from 'react';
export function Colors() {
return <div></div>;
}
export default Colors;

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import React from 'react';
export function UploadImage() {
return <div></div>;
}
export default UploadImage;

View File

@ -0,0 +1,4 @@
export * from './Unsplash';
export * from './UploadImage';
export * from './EmbedLink';
export * from './Colors';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
};
}, []);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,2 @@
export * from './shortcuts.hooks';
export * from './withShortcuts';
export * from './hotkey';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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(' + ');
};

View File

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

View File

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