diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index 87249b6f2c..6f8ed203a0 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -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" diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index b2b708cb99..a6d544c10a 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -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 diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts index 3d187336d2..7e4ac9e636 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg new file mode 100644 index 0000000000..3e86e21b8d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/image.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx new file mode 100644 index 0000000000..af3a91b3b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Colors.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function Colors() { + return
; +} + +export default Colors; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx new file mode 100644 index 0000000000..40a46fed81 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/EmbedLink.tsx @@ -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) => { + 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 ( +
+ + +
+ ); +} + +export default EmbedLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx new file mode 100644 index 0000000000..01da8323b9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/Unsplash.tsx @@ -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(''); + 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) => { + 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 ( +
+ + + {loading ? ( +
+ +
{t('editor.loading')}
+
+ ) : error ? ( + + {error} + + ) : ( +
+ {photos.length > 0 ? ( + <> +
+ {photos.map((photo) => ( +
+ { + onDone?.(photo.regular); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={'h-20 w-32 rounded object-cover hover:opacity-80'} + /> +
+ by{' '} + { + void open(photo.user.link); + }} + className={'underline hover:text-function-info'} + > + {photo.user.name} + +
+
+ ))} +
+ + {t('findAndReplace.searchMore')} + + + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ )} +
+ ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx new file mode 100644 index 0000000000..7f3d828149 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/UploadImage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export function UploadImage() { + return
; +} + +export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts new file mode 100644 index 0000000000..f2eab1116b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/image_upload/index.ts @@ -0,0 +1,4 @@ +export * from './Unsplash'; +export * from './UploadImage'; +export * from './EmbedLink'; +export * from './Colors'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts index bb531ba551..7554c21bb0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts @@ -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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts index 40b4a6c458..a8135da1cc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -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; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + cloneBlock(editor: ReactEditor, block: Element): Element { const cloneNode: Element = { ...cloneDeep(block), diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx new file mode 100644 index 0000000000..b3d3575af2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageActions.tsx @@ -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); + 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) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'center' && { + key: ImageAction.AlignCenter, + Icon: AlignCenterIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + setAlignAnchorEl(e.currentTarget); + }, + }, + align === 'right' && { + key: ImageAction.AlignRight, + Icon: AlignRightIcon, + tooltip: t('button.align'), + onClick: (e: React.MouseEvent) => { + 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>; + tooltip: string; + onClick: (e: React.MouseEvent) => void; + }[]; + }, [align, node, t, editor]); + + return ( +
+ {options.map((option) => { + const { key, Icon, tooltip, onClick } = option; + + return ( + + + + + + ); + })} + {!!alignAnchorEl && ( + setAlignAnchorEl(null)} + > + {alignOptions.map((option) => { + const { key, Icon, onClick } = option; + + return ( + + + + ); + })} + + )} +
+ ); +} + +export default ImageActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx new file mode 100644 index 0000000000..6d3a7cfd22 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageBlock.tsx @@ -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>(({ node, children, className, ...attributes }, ref) => { + const selected = useSelected(); + const { url, align } = node.data; + const containerRef = useRef(null); + const editor = useSlateStatic(); + const onFocusNode = useCallback(() => { + ReactEditor.focus(editor); + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + }, [editor, node]); + + return ( +
{ + if (!selected) onFocusNode(); + }} + className={`${className} image-block relative w-full cursor-pointer py-1`} + > +
+ {children} +
+
+ {url ? ( + + ) : ( + + )} +
+
+ ); + }) +); + +export default ImageBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx new file mode 100644 index 0000000000..56da74ff05 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageEmpty.tsx @@ -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; + 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 ( + <> +
+ + {t('document.plugins.image.addAnImage')} +
+ {open && ( + { + closePopover(EditorNodeType.ImageBlock); + onEscape(); + }} + /> + )} + + ); +} + +export default ImageEmpty; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx new file mode 100644 index 0000000000..01e4df6c5c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageRender.tsx @@ -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(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(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 ( + <> +
{ + setShowActions(true); + }} + onMouseLeave={() => { + setShowActions(false); + }} + className={'relative'} + > + { + 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 && } + {showActions && } +
+ + {loading && ( +
+ +
{t('editor.loading')}
+
+ )} + {hasError && ( +
+ +
{t('editor.imageLoadFailed')}
+
+ )} + + ); +} + +export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx new file mode 100644 index 0000000000..a2164202a6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/ImageResizer.tsx @@ -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 ( +
{ + originalWidth.current = width; + }} + style={{ + right: '2px', + }} + className={'image-resizer'} + > +
+
+ ); +} + +export default ImageResizer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx new file mode 100644 index 0000000000..1c46776063 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/UploadPopover.tsx @@ -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(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 ( + { + e.stopPropagation(); + }} + onKeyDown={onKeyDown} + PaperProps={{ + style: { + padding: 0, + }, + }} + > +
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + + +
+ + {tabOptions.map((tab, index) => { + const { key, Component, onDone } = tab; + + return ( + + + + ); + })} + +
+
+
+ ); +} + +export default UploadPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts new file mode 100644 index 0000000000..73c3003a92 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/image/index.ts @@ -0,0 +1 @@ +export * from './ImageBlock'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx index 71b5d0f706..542eb977d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx @@ -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>( @@ -12,7 +13,9 @@ export const MathEquation = memo( const formula = node.data.formula; const { t } = useTranslation(); const containerRef = useRef(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`} >
{ - setOpen(false); + closePopover(EditorNodeType.EquationBlock); }} node={node} open={open} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx index 18f7bd8c03..3ac4f1e5c6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -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 ( - - - - - + + + + + + - - -
- - - + + +
+ + + + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx index f11551c5d8..903ae5741f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -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; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx index 0bc9a59a3f..3cb9dae044 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx @@ -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(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx index 4a0cc3e33c..b9ca0345af 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditInput.tsx @@ -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, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx index 624b9ff0f1..20c326d640 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx index c55462f87b..1ebb783871 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx @@ -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 = { @@ -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', }; }, []); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx index 5cb6e96596..3dab0fa182 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx @@ -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 (
{!isAcrossBlocks && ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts index 29ce475e45..2208058b61 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -14,6 +14,7 @@ export function useSelectionToolbar(ref: MutableRefObject 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 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 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 restoreSelection, storeSelection, isAcrossBlocks, + isIncludeRoot, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx index 214fa1730a..d4ca9c9de0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx @@ -6,7 +6,7 @@ import withErrorBoundary from '$app/components/_shared/error_boundary/withError' const Toolbar = memo(() => { const ref = useRef(null); - const { visible, restoreSelection, storeSelection, isAcrossBlocks } = useSelectionToolbar(ref); + const { visible, restoreSelection, storeSelection, isAcrossBlocks, isIncludeRoot } = useSelectionToolbar(ref); return (
{ }} > { 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 ( 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 ( { if (isHotkey('mod+k', e)) { + if (editor.selection && Range.isCollapsed(editor.selection)) return; e.preventDefault(); e.stopPropagation(); onClick(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx index ef761e5c8c..39b48ad525 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/inline_code/InlineCode.tsx @@ -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 ( 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 ( 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 ( 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 ( { - [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]; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts index fc262b9036..7cfd550743 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/index.ts @@ -1,3 +1,2 @@ export * from './shortcuts.hooks'; export * from './withShortcuts'; -export * from './hotkey'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts index 03a6833bd2..a22c5b7544 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts @@ -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) => { - 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)) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts new file mode 100644 index 0000000000..00992964fb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/block.ts @@ -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(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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts index e93794da88..22f0bb81be 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/index.ts @@ -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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts index e3a28ff5fd..803f474723 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/stores/selected.ts @@ -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); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx index f977bbe852..14bc179189 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -36,7 +36,7 @@ function Layout({ children }: { children: ReactNode }) {
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx index ca37d8aedd..0dfe7e51f3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx @@ -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() { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx index e0a36a5903..94a86655ac 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/MoreButton.tsx @@ -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({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts deleted file mode 100644 index a81e5e9093..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/get_modifier.ts +++ /dev/null @@ -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; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts new file mode 100644 index 0000000000..fab7f0612f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts @@ -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) => { + const keys = customHotKeys || defaultHotKeys; + const hotkey = keys[hotkeyName]; + + return (event: KeyboardEvent) => { + return isHotkey(hotkey, event); + }; +}; + +export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { + 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(' + '); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts index f14b256517..3fd9933a45 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -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)) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 3c6bf76dac..5ceecd7cc9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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",