diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx index e815597456..8cb2b16b12 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewBanner.tsx @@ -22,7 +22,7 @@ function ViewBanner({
{showCover && cover && } -
+
+
{showAddIcon && ( - )} {showAddCover && ( - )} 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 91e6ecd76e..c6c17a1008 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 @@ -75,16 +75,45 @@ export const CustomEditor = { if (!afterPoint) return false; return CustomEditor.isInlineNode(editor, afterPoint); }, - blockEqual: (editor: ReactEditor, point: Point, anotherPoint: Point) => { - const match = CustomEditor.getBlock(editor, point); - const anotherMatch = CustomEditor.getBlock(editor, anotherPoint); - if (!match || !anotherMatch) return false; + isMultipleBlockSelected: (editor: ReactEditor, filterEmpty = false) => { + const { selection } = editor; - const [node] = match; - const [anotherNode] = anotherMatch; + if (!selection) return false; - return node === anotherNode; + const start = selection.anchor; + const end = selection.focus; + const startBlock = CustomEditor.getBlock(editor, start); + const endBlock = CustomEditor.getBlock(editor, end); + + if (!startBlock || !endBlock) return false; + + const [, startPath] = startBlock; + const [, endPath] = endBlock; + const pathIsEqual = Path.equals(startPath, endPath); + + if (pathIsEqual) { + return false; + } + + if (!filterEmpty) { + return true; + } + + const notEmptyBlocks = Array.from( + editor.nodes({ + match: (n) => { + return ( + !Editor.isEditor(n) && + Element.isElement(n) && + n.blockId !== undefined && + !CustomEditor.isEmptyText(editor, n) + ); + }, + }) + ); + + return notEmptyBlocks.length > 1; }, /** @@ -109,6 +138,10 @@ export const CustomEditor = { const cloneNode = CustomEditor.cloneBlock(editor, node); Object.assign(cloneNode, newProperties); + cloneNode.data = { + ...(node.data || {}), + ...(newProperties.data || {}), + }; const isEmbed = editor.isEmbed(cloneNode); @@ -273,18 +306,35 @@ export const CustomEditor = { }); }, - toggleTodo(editor: ReactEditor, node: TodoListNode) { - const checked = node.data.checked; - const path = ReactEditor.findPath(editor, node); - const data = node.data || {}; - const newProperties = { - data: { - ...data, - checked: !checked, - }, - } as Partial; + toggleTodo(editor: ReactEditor, at?: Location) { + const selection = at || editor.selection; - Transforms.setNodes(editor, newProperties, { at: path }); + if (!selection) return; + + const nodes = Array.from( + editor.nodes({ + at: selection, + match: (n) => Element.isElement(n) && n.type === EditorNodeType.TodoListBlock, + }) + ); + + const matchUnChecked = nodes.some(([node]) => { + return !(node as TodoListNode).data.checked; + }); + + const checked = Boolean(matchUnChecked); + + nodes.forEach(([node, path]) => { + const data = (node as TodoListNode).data || {}; + const newProperties = { + data: { + ...data, + checked: checked, + }, + } as Partial; + + Transforms.setNodes(editor, newProperties, { at: path }); + }); }, toggleToggleList(editor: ReactEditor, node: ToggleListNode) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx index c10dde829a..2178dc3450 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx @@ -38,15 +38,15 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className? } case EditorNodeType.ToggleListBlock: - return t('document.plugins.toggleList'); + return t('blockPlaceholders.bulletList'); case EditorNodeType.QuoteBlock: - return t('editor.quote'); + return t('blockPlaceholders.quote'); case EditorNodeType.TodoListBlock: - return t('document.plugins.todoList'); + return t('blockPlaceholders.todoList'); case EditorNodeType.NumberedListBlock: - return t('document.plugins.numberedList'); + return t('blockPlaceholders.numberList'); case EditorNodeType.BulletedListBlock: - return t('document.plugins.bulletedList'); + return t('blockPlaceholders.bulletList'); case EditorNodeType.HeadingBlock: { const level = (block as HeadingNode).data.level; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx index 0fc8601f73..4805233e1d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx @@ -100,6 +100,11 @@ function SelectLanguage({ ref={ref} size={'small'} variant={'standard'} + sx={{ + '& .MuiInputBase-root, & .MuiInputBase-input': { + userSelect: 'none', + }, + }} className={'w-[150px]'} value={language} onClick={() => { @@ -115,6 +120,7 @@ function SelectLanguage({ {open && ( -
{item.name || t('document.title.placeholder')}
+
{item.name.trim() || t('menuAppHeader.defaultNewPageName')}
); }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx index 763a0983fa..d7d475199b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx @@ -15,7 +15,7 @@ export const DividerNode = memo( return (
-
+

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 index 9d2b4fdac0..661eb3e3de 100644 --- 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 @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useRef } from 'react'; +import React, { forwardRef, memo, useCallback, useMemo, 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'; @@ -7,7 +7,7 @@ import ImageEmpty from '$app/components/editor/components/blocks/image/ImageEmpt export const ImageBlock = memo( forwardRef>(({ node, children, className, ...attributes }, ref) => { const selected = useSelected(); - const { url, align } = node.data; + const { url, align } = useMemo(() => node.data || {}, [node.data]); const containerRef = useRef(null); const editor = useSlateStatic(); const onFocusNode = useCallback(() => { 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 index 153e6f6a1c..07310b05be 100644 --- 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 @@ -20,7 +20,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) const imgRef = useRef(null); const editor = useSlateStatic(); - const { url = '', width: imageWidth, image_type: source } = node.data; + const { url = '', width: imageWidth, image_type: source } = useMemo(() => node.data || {}, [node.data]); const { t } = useTranslation(); const blockId = node.blockId; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx index b745530acc..acf16581f4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/StartIcon.hooks.tsx @@ -36,7 +36,7 @@ export function useStartIcon(node: TextNode) { return null; } - return ; + return ; }, [Component, block]); return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx index 6ff97c2836..00ad26b6d9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx @@ -14,9 +14,9 @@ export const Text = memo( {renderIcon()} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx index 630aa93fb7..d98990c886 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/CheckboxIcon.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react'; import { TodoListNode } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { useSlateStatic } from 'slate-react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Location } from 'slate'; import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; @@ -9,9 +10,25 @@ function CheckboxIcon({ block, className }: { block: TodoListNode; className: st const editor = useSlateStatic(); const { checked } = block.data; - const toggleTodo = useCallback(() => { - CustomEditor.toggleTodo(editor, block); - }, [editor, block]); + const toggleTodo = useCallback( + (e: React.MouseEvent) => { + const path = ReactEditor.findPath(editor, block); + const start = editor.start(path); + let at: Location = start; + + if (e.shiftKey) { + const end = editor.end(path); + + at = { + anchor: start, + focus: end, + }; + } + + CustomEditor.toggleTodo(editor, at); + }, + [editor, block] + ); return ( >(({ node, children, ...attributes }, ref) => { - const { checked } = node.data; + const { checked = false } = useMemo(() => node.data || {}, [node.data]); const className = useMemo(() => { return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`; }, [attributes.className, checked]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx index 8af826ae22..809f3b750d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx @@ -1,9 +1,9 @@ -import React, { forwardRef, memo } from 'react'; +import React, { forwardRef, memo, useMemo } from 'react'; import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types'; export const ToggleList = memo( forwardRef>(({ node, children, ...attributes }, ref) => { - const { collapsed } = node.data; + const { collapsed } = useMemo(() => node.data || {}, [node.data]); const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''}`; return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts index 0dcb54cfd3..a5271eb9b9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts @@ -3,7 +3,6 @@ import { KeyboardEvent, useCallback, useEffect, useMemo } from 'react'; import { BaseRange, createEditor, Editor, NodeEntry, Range, Transforms, Element } from 'slate'; import { ReactEditor, withReact } from 'slate-react'; import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins'; -import { withShortcuts } from 'src/appflowy_app/components/editor/plugins/shortcuts'; import { withInlines } from '$app/components/editor/components/inline_nodes'; import { withYHistory, withYjs, YjsEditor } from '@slate-yjs/core'; import * as Y from 'yjs'; @@ -11,11 +10,12 @@ import { CustomEditor } from '$app/components/editor/command'; import { CodeNode, EditorNodeType } from '$app/application/document/document.types'; import { decorateCode } from '$app/components/editor/components/blocks/code/utils'; import isHotkey from 'is-hotkey'; +import { withMarkdown } from '$app/components/editor/plugins/shortcuts'; export function useEditor(sharedType: Y.XmlText) { const editor = useMemo(() => { if (!sharedType) return null; - const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); + const e = withMarkdown(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); // Ensure editor always has at least 1 valid child const { normalizeNode } = e; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx index dff7b7dae6..09095480dc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/Link.tsx @@ -39,7 +39,7 @@ export const Link = memo(({ children }: { leaf: Text; children: React.ReactNode {children} 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 3d7dff3cb4..af62a7b28f 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 @@ -14,7 +14,7 @@ import KeyboardNavigation, { } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import isHotkey from 'is-hotkey'; import LinkEditInput from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; -import { openUrl, pattern } from '$app/utils/open_url'; +import { openUrl, isUrl } from '$app/utils/open_url'; function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) { const editor = useSlateStatic(); @@ -59,7 +59,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul if (e.key === 'Enter') { e.preventDefault(); - if (pattern.test(link)) { + if (isUrl(link)) { onClose(); setNodeMark(); } @@ -125,7 +125,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul return [ { key: 'open', - disabled: !pattern.test(link), + disabled: !isUrl(link), content: renderOption(, t('editor.openLink')), }, { 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 b9ca0345af..6e9a0bb497 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,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { pattern } from '$app/utils/open_url'; +import { isUrl } from '$app/utils/open_url'; function LinkEditInput({ link, @@ -16,7 +16,7 @@ function LinkEditInput({ const [error, setError] = useState(null); useEffect(() => { - if (pattern.test(link)) { + if (isUrl(link)) { setError(null); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx index 488784d38b..2a5e3630da 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditPopover.tsx @@ -60,7 +60,7 @@ export function LinkEditPopover({ style={{ maxHeight: paperHeight, }} - className='flex flex-col p-4' + className='flex select-none flex-col p-4' >
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx index 12e5bab14d..2da09a371d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx @@ -132,7 +132,7 @@ export function MentionLeaf({ mention }: { mention: Mention }) { page && ( <> {page.icon?.value || } - {page.name || t('document.title.placeholder')} + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} ) )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts index b35239ae21..897658a16d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts @@ -49,10 +49,19 @@ export function useBlockActionsToolbar(ref: RefObject, contextMe try { range = ReactEditor.findEventRange(editor, e); } catch { - range = findEventRange(editor, e); + const editorDom = ReactEditor.toDOMNode(editor, editor); + + range = findEventRange(editor, { + ...e, + clientX: e.clientX + editorDom.offsetWidth / 2, + clientY: e.clientY, + }); + } + + if (!range) { + return; } - if (!range) return; const match = editor.above({ match: (n) => { return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts index 4166022182..7e87461d29 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts @@ -2,6 +2,7 @@ import { ReactEditor } from 'slate-react'; import { getEditorDomNode, getHeadingCssProperty } from '$app/components/editor/plugins/utils'; import { Element } from 'slate'; import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; export function getBlockActionsPosition(editor: ReactEditor, blockElement: HTMLElement) { const editorDom = getEditorDomNode(editor); @@ -58,30 +59,8 @@ export function findEventRange(editor: ReactEditor, e: MouseEvent) { } } - if (domRange && domRange.startContainer) { - const startContainer = domRange.startContainer; - - let element: HTMLElement | null = startContainer as HTMLElement; - const nodeType = element.nodeType; - - if (nodeType === 3 || typeof element === 'string') { - const parent = element.parentElement?.closest('.text-block-icon') as HTMLElement; - - element = parent; - } - - if (element && element.nodeType < 3) { - if (element.classList?.contains('text-block-icon')) { - const sibling = domRange.startContainer.parentElement; - - if (sibling) { - domRange.selectNode(sibling); - } - } - } - } - if (!domRange) { + Log.warn('Could not find a range from the caret position.'); return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx index 806a2a3788..e70c22d36f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.tsx @@ -67,7 +67,7 @@ export function useMentionPanel({
{page.icon?.value || }
-
{page.name || t('document.title.placeholder')}
+
{page.name.trim() || t('menuAppHeader.defaultNewPageName')}
), }; 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 2208058b61..773372a8bc 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 @@ -109,7 +109,10 @@ export function useSelectionToolbar(ref: MutableRefObject useEffect(() => { const decorateState = getStaticState(); - if (decorateState) return; + if (decorateState) { + setIsAcrossBlocks(false); + return; + } const { selection } = editor; @@ -131,10 +134,7 @@ export function useSelectionToolbar(ref: MutableRefObject return; } - const start = selection.anchor; - const end = selection.focus; - - setIsAcrossBlocks(!CustomEditor.blockEqual(editor, start, end)); + setIsAcrossBlocks(CustomEditor.isMultipleBlockSelected(editor, true)); debounceRecalculatePosition(); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx index e715411bf5..006247ca8b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/numbered_list/NumberedList.tsx @@ -12,10 +12,16 @@ export function NumberedList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.NumberedListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.NumberedListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.NumberedListBlock, + type, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx index 2076c84b1b..29ad0de104 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/quote/Quote.tsx @@ -12,10 +12,16 @@ export function Quote() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.QuoteBlock); const onClick = useCallback(() => { + let type = EditorNodeType.QuoteBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.QuoteBlock, + type, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx index 127e81106e..cd576edafa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/todo_list/TodoList.tsx @@ -13,10 +13,19 @@ export function TodoList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.TodoListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.TodoListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.TodoListBlock, + type, + data: { + checked: false, + }, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx index 1302a84a87..4d82652988 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/toggle_list/ToggleList.tsx @@ -12,10 +12,19 @@ export function ToggleList() { const isActivated = CustomEditor.isBlockActive(editor, EditorNodeType.ToggleListBlock); const onClick = useCallback(() => { + let type = EditorNodeType.ToggleListBlock; + + if (isActivated) { + type = EditorNodeType.Paragraph; + } + CustomEditor.turnToBlock(editor, { - type: EditorNodeType.ToggleListBlock, + type, + data: { + collapsed: false, + }, }); - }, [editor]); + }, [editor, isActivated]); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss index e26d842317..7096955296 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss @@ -13,17 +13,20 @@ .block-element.block-align-left { > div > .text-element { + text-align: left; justify-content: flex-start; } } .block-element.block-align-right { > div > .text-element { + text-align: right; justify-content: flex-end; } } .block-element.block-align-center { > div > .text-element { + text-align: center; justify-content: center; } @@ -84,8 +87,8 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .text-content, [data-dark-mode="true"] .text-content { + @apply min-w-[1px]; &.empty-content { - @apply min-w-[1px]; span { &::selection { @apply bg-transparent; @@ -103,7 +106,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .text-placeholder { &:after { - @apply text-text-placeholder absolute left-1.5 top-1/2 transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap; + @apply text-text-placeholder absolute left-[5px] top-1/2 transform -translate-y-1/2 pointer-events-none select-none whitespace-nowrap; content: (attr(placeholder)); } } 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 7cfd550743..0292784ba5 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,2 +1,2 @@ export * from './shortcuts.hooks'; -export * from './withShortcuts'; +export * from './withMarkdown'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts new file mode 100644 index 0000000000..65072017e4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/markdown.ts @@ -0,0 +1,172 @@ +export type MarkdownRegex = { + [key in MarkdownShortcuts]: { + pattern: RegExp; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record; + }[]; +}; + +export type TriggerHotKey = { + [key in MarkdownShortcuts]: string[]; +}; + +export enum MarkdownShortcuts { + Bold, + Italic, + StrikeThrough, + Code, + Equation, + /** block */ + Heading, + BlockQuote, + CodeBlock, + Divider, + /** list */ + BulletedList, + NumberedList, + TodoList, + ToggleList, +} + +const defaultMarkdownRegex: MarkdownRegex = { + [MarkdownShortcuts.Heading]: [ + { + pattern: /^#{1,6}$/, + }, + ], + [MarkdownShortcuts.Bold]: [ + { + pattern: /(\*\*|__)(.*?)(\*\*|__)$/, + }, + ], + [MarkdownShortcuts.Italic]: [ + { + pattern: /([*_])(.*?)([*_])$/, + }, + ], + [MarkdownShortcuts.StrikeThrough]: [ + { + pattern: /(~~)(.*?)(~~)$/, + }, + { + pattern: /(~)(.*?)(~)$/, + }, + ], + [MarkdownShortcuts.Code]: [ + { + pattern: /(`)(.*?)(`)$/, + }, + ], + [MarkdownShortcuts.Equation]: [ + { + pattern: /(\$)(.*?)(\$)$/, + data: { + formula: '', + }, + }, + ], + [MarkdownShortcuts.BlockQuote]: [ + { + pattern: /^([”“"])$/, + }, + ], + [MarkdownShortcuts.CodeBlock]: [ + { + pattern: /^(`{3,})$/, + data: { + language: 'json', + }, + }, + ], + [MarkdownShortcuts.Divider]: [ + { + pattern: /^(([-*]){3,})$/, + }, + ], + + [MarkdownShortcuts.BulletedList]: [ + { + pattern: /^([*\-+])$/, + }, + ], + [MarkdownShortcuts.NumberedList]: [ + { + pattern: /^(\d+)\.$/, + }, + ], + [MarkdownShortcuts.TodoList]: [ + { + pattern: /^(-)?\[ ]$/, + data: { + checked: false, + }, + }, + { + pattern: /^(-)?\[x]$/, + data: { + checked: true, + }, + }, + { + pattern: /^(-)?\[]$/, + data: { + checked: false, + }, + }, + ], + [MarkdownShortcuts.ToggleList]: [ + { + pattern: /^>$/, + data: { + collapsed: false, + }, + }, + ], +}; + +export const defaultTriggerChar: TriggerHotKey = { + [MarkdownShortcuts.Heading]: [' '], + [MarkdownShortcuts.Bold]: ['*', '_'], + [MarkdownShortcuts.Italic]: ['*', '_'], + [MarkdownShortcuts.StrikeThrough]: ['~'], + [MarkdownShortcuts.Code]: ['`'], + [MarkdownShortcuts.BlockQuote]: [' '], + [MarkdownShortcuts.CodeBlock]: ['`'], + [MarkdownShortcuts.Divider]: ['-', '*'], + [MarkdownShortcuts.Equation]: ['$'], + [MarkdownShortcuts.BulletedList]: [' '], + [MarkdownShortcuts.NumberedList]: [' '], + [MarkdownShortcuts.TodoList]: [' '], + [MarkdownShortcuts.ToggleList]: [' '], +}; + +export function isTriggerChar(char: string) { + return Object.values(defaultTriggerChar).some((trigger) => trigger.includes(char)); +} + +export function whatShortcutTrigger(char: string): MarkdownShortcuts[] | null { + const isTrigger = isTriggerChar(char); + + if (!isTrigger) { + return null; + } + + const shortcuts = Object.keys(defaultTriggerChar).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => defaultTriggerChar[shortcut].includes(char)); +} + +export function getRegex(shortcut: MarkdownShortcuts) { + return defaultMarkdownRegex[shortcut]; +} + +export function whatShortcutsMatch(text: string) { + const shortcuts = Object.keys(defaultMarkdownRegex).map((key) => Number(key) as MarkdownShortcuts); + + return shortcuts.filter((shortcut) => { + const regexes = defaultMarkdownRegex[shortcut]; + + return regexes.some((regex) => regex.pattern.test(text)); + }); +} 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 a22c5b7544..eb4cf8078f 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,10 +1,11 @@ import { ReactEditor } from 'slate-react'; import { useCallback, KeyboardEvent } from 'react'; -import { EditorNodeType, TodoListNode, ToggleListNode } from '$app/application/document/document.types'; +import { EditorNodeType, 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 { createHotkey, HOT_KEY_NAME } from '$app/utils/hotkeys'; /** * Hotkeys shortcuts @@ -65,18 +66,18 @@ export function useShortcuts(editor: ReactEditor) { return; } - if (isHotkey('mod+Enter', e) && node) { - if (node.type === EditorNodeType.TodoListBlock) { - e.preventDefault(); - CustomEditor.toggleTodo(editor, node as TodoListNode); - return; - } + if (createHotkey(HOT_KEY_NAME.TOGGLE_TODO)(e.nativeEvent)) { + e.preventDefault(); + CustomEditor.toggleTodo(editor); + } - if (node.type === EditorNodeType.ToggleListBlock) { - e.preventDefault(); - CustomEditor.toggleToggleList(editor, node as ToggleListNode); - return; - } + if ( + createHotkey(HOT_KEY_NAME.TOGGLE_COLLAPSE)(e.nativeEvent) && + node && + node.type === EditorNodeType.ToggleListBlock + ) { + e.preventDefault(); + CustomEditor.toggleToggleList(editor, node as ToggleListNode); } if (isHotkey('shift+backspace', e)) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts new file mode 100644 index 0000000000..1a78bdc8cc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdown.ts @@ -0,0 +1,219 @@ +import { Range, Element, Editor, NodeEntry } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { + getRegex, + MarkdownShortcuts, + whatShortcutsMatch, + whatShortcutTrigger, +} from '$app/components/editor/plugins/shortcuts/markdown'; +import { CustomEditor } from '$app/components/editor/command'; +import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; +import isEqual from 'lodash-es/isEqual'; + +export const withMarkdown = (editor: ReactEditor) => { + const { insertText } = editor; + + editor.insertText = (char) => { + const { selection } = editor; + + insertText(char); + if (!selection || !Range.isCollapsed(selection)) { + return; + } + + const triggerShortcuts = whatShortcutTrigger(char); + + if (!triggerShortcuts) { + return; + } + + const match = CustomEditor.getBlock(editor); + const [node, path] = match as NodeEntry; + const start = Editor.start(editor, path); + const beforeRange = { anchor: start, focus: selection.anchor }; + const beforeText = Editor.string(editor, beforeRange); + + const removeBeforeText = (beforeRange: Range) => { + editor.deleteBackward('character'); + editor.delete({ + at: beforeRange, + }); + }; + + const matchBlockShortcuts = whatShortcutsMatch(beforeText); + + for (const shortcut of matchBlockShortcuts) { + const block = whichBlock(shortcut, beforeText); + + // if the block shortcut is matched, remove the before text and turn to the block + // then return + if (block) { + // Don't turn to the block condition + // 1. Heading should be able to co-exist with number list + if (block.type === EditorNodeType.NumberedListBlock && node.type === EditorNodeType.HeadingBlock) { + return; + } + + // 2. If the block is the same type, and data is the same + if (block.type === node.type && isEqual(block.data || {}, node.data || {})) { + return; + } + + removeBeforeText(beforeRange); + CustomEditor.turnToBlock(editor, block); + + return; + } + } + + // get the range that matches the mark shortcuts + const markRange = { + anchor: Editor.start(editor, selection.anchor.path), + focus: selection.focus, + }; + const rangeText = Editor.string(editor, markRange) + char; + + if (!rangeText) return; + + // inputting a character that is start of a mark + const isStartTyping = rangeText.indexOf(char) === rangeText.lastIndexOf(char); + + if (isStartTyping) return; + + // if the range text includes a double character mark, and the last one is not finished + const doubleCharNotFinish = + ['*', '_', '~'].includes(char) && + rangeText.indexOf(`${char}${char}`) > -1 && + rangeText.indexOf(`${char}${char}`) === rangeText.lastIndexOf(`${char}${char}`); + + if (doubleCharNotFinish) return; + + const matchMarkShortcuts = whatShortcutsMatch(rangeText); + + for (const shortcut of matchMarkShortcuts) { + const item = getRegex(shortcut).find((p) => p.pattern.test(rangeText)); + const execArr = item?.pattern?.exec(rangeText); + + const removeText = execArr ? execArr[0] : ''; + + const text = execArr ? execArr[2].replaceAll(char, '') : ''; + + if (text) { + const index = rangeText.indexOf(removeText); + const removeRange = { + anchor: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index, + }, + focus: { + path: markRange.anchor.path, + offset: markRange.anchor.offset + index + removeText.length, + }, + }; + + removeBeforeText(removeRange); + insertMark(editor, shortcut, text); + return; + } + } + }; + + return editor; +}; + +function whichBlock(shortcut: MarkdownShortcuts, beforeText: string) { + switch (shortcut) { + case MarkdownShortcuts.Heading: + return { + type: EditorNodeType.HeadingBlock, + data: { + level: beforeText.length, + }, + }; + case MarkdownShortcuts.CodeBlock: + return { + type: EditorNodeType.CodeBlock, + data: { + language: 'json', + }, + }; + case MarkdownShortcuts.BulletedList: + return { + type: EditorNodeType.BulletedListBlock, + data: {}, + }; + case MarkdownShortcuts.NumberedList: + return { + type: EditorNodeType.NumberedListBlock, + data: {}, + }; + case MarkdownShortcuts.TodoList: + return { + type: EditorNodeType.TodoListBlock, + data: { + checked: beforeText.includes('[x]'), + }, + }; + case MarkdownShortcuts.BlockQuote: + return { + type: EditorNodeType.QuoteBlock, + data: {}, + }; + case MarkdownShortcuts.Divider: + return { + type: EditorNodeType.DividerBlock, + data: {}, + }; + + case MarkdownShortcuts.ToggleList: + return { + type: EditorNodeType.ToggleListBlock, + data: { + collapsed: false, + }, + }; + + default: + return null; + } +} + +function insertMark(editor: ReactEditor, shortcut: MarkdownShortcuts, text: string) { + switch (shortcut) { + case MarkdownShortcuts.Bold: + case MarkdownShortcuts.Italic: + case MarkdownShortcuts.StrikeThrough: + case MarkdownShortcuts.Code: { + const textNode = { + text, + }; + const attributes = { + [MarkdownShortcuts.Bold]: { + [EditorMarkFormat.Bold]: true, + }, + [MarkdownShortcuts.Italic]: { + [EditorMarkFormat.Italic]: true, + }, + [MarkdownShortcuts.StrikeThrough]: { + [EditorMarkFormat.StrikeThrough]: true, + }, + [MarkdownShortcuts.Code]: { + [EditorMarkFormat.Code]: true, + }, + }; + + Object.assign(textNode, attributes[shortcut]); + + editor.insertNodes(textNode); + return; + } + + case MarkdownShortcuts.Equation: { + CustomEditor.insertFormula(editor, text); + return; + } + + default: + return null; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts deleted file mode 100644 index f49f4d9dda..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withMarkdownShortcuts.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { ReactEditor } from 'slate-react'; -import { Editor, Range, Element as SlateElement, Transforms } from 'slate'; -import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; -import { CustomEditor } from '$app/components/editor/command'; - -/** - * Markdown shortcuts - * @description - * - bold: **bold** or __bold__ - * - italic: *italic* or _italic_ - * - strikethrough: ~~strikethrough~~ or ~strikethrough~ - * - code: `code` - * - heading: # or ## or ### - * - bulleted list: * or - or + - * - number list: 1. or 2. or 3. - * - toggle list: > - * - quote: ” or “ or " - * - todo list: -[ ] or -[x] or -[] or [] or [x] or [ ] - * - code block: ``` - * - callout: [!TIP] or [!INFO] or [!WARNING] or [!DANGER] - * - divider: ---or*** - * - equation: $$formula$$ - */ - -const regexMap: Record< - string, - { - pattern: RegExp; - data?: Record; - }[] -> = { - [EditorNodeType.BulletedListBlock]: [ - { - pattern: /^([*\-+])$/, - }, - ], - [EditorNodeType.ToggleListBlock]: [ - { - pattern: /^>$/, - data: { - collapsed: false, - }, - }, - ], - [EditorNodeType.QuoteBlock]: [ - { - pattern: /^”$/, - }, - { - pattern: /^“$/, - }, - { - pattern: /^"$/, - }, - ], - [EditorNodeType.TodoListBlock]: [ - { - pattern: /^(-)?\[ ]$/, - data: { - checked: false, - }, - }, - { - pattern: /^(-)?\[x]$/, - data: { - checked: true, - }, - }, - { - pattern: /^(-)?\[]$/, - data: { - checked: false, - }, - }, - ], - [EditorNodeType.NumberedListBlock]: [ - { - pattern: /^(\d+)\.$/, - }, - ], - [EditorNodeType.HeadingBlock]: [ - { - pattern: /^#$/, - data: { - level: 1, - }, - }, - { - pattern: /^#{2}$/, - data: { - level: 2, - }, - }, - { - pattern: /^#{3}$/, - data: { - level: 3, - }, - }, - ], - [EditorNodeType.CodeBlock]: [ - { - pattern: /^(`{3,})$/, - data: { - language: 'json', - }, - }, - ], - [EditorNodeType.CalloutBlock]: [ - { - pattern: /^\[!TIP]$/, - data: { - icon: '💡', - }, - }, - { - pattern: /^\[!INFO]$/, - data: { - icon: 'ℹ️', - }, - }, - { - pattern: /^\[!WARNING]$/, - data: { - icon: '⚠️', - }, - }, - { - pattern: /^\[!DANGER]$/, - data: { - icon: '🚨', - }, - }, - ], - [EditorNodeType.DividerBlock]: [ - { - pattern: /^(([-*]){3,})$/, - }, - ], - [EditorNodeType.EquationBlock]: [ - { - pattern: /^\$\$(.*)\$\$$/, - data: { - formula: '', - }, - }, - ], -}; - -const blockCommands = [' ', '-', '`', '$', '*']; - -const CharToMarkTypeMap: Record = { - '**': EditorMarkFormat.Bold, - __: EditorMarkFormat.Bold, - '*': EditorMarkFormat.Italic, - _: EditorMarkFormat.Italic, - '~': EditorMarkFormat.StrikeThrough, - '~~': EditorMarkFormat.StrikeThrough, - '`': EditorMarkFormat.Code, -}; - -const inlineBlockCommands = ['*', '_', '~', '`']; -const doubleCharCommands = ['*', '_', '~']; - -const matchBlockShortcutType = (beforeText: string, endChar: string) => { - // end with divider char: - - if (endChar === '-' || endChar === '*') { - const dividerRegex = regexMap[EditorNodeType.DividerBlock][0]; - - return dividerRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.DividerBlock, - data: {}, - } - : null; - } - - // end with code block char: ` - if (endChar === '`') { - const codeBlockRegex = regexMap[EditorNodeType.CodeBlock][0]; - - return codeBlockRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.CodeBlock, - data: codeBlockRegex.data, - } - : null; - } - - if (endChar === '$') { - const equationBlockRegex = regexMap[EditorNodeType.EquationBlock][0]; - - const match = equationBlockRegex.pattern.exec(beforeText + endChar); - - const formula = match?.[1]; - - return equationBlockRegex.pattern.test(beforeText + endChar) - ? { - type: EditorNodeType.EquationBlock, - data: { - formula, - }, - } - : null; - } - - for (const [type, regexes] of Object.entries(regexMap)) { - for (const regex of regexes) { - if (regex.pattern.test(beforeText)) { - return { - type, - data: regex.data, - }; - } - } - } - - return null; -}; - -export const withMarkdownShortcuts = (editor: ReactEditor) => { - const { insertText } = editor; - - editor.insertText = (text) => { - if (CustomEditor.isCodeBlock(editor) || CustomEditor.selectionIncludeRoot(editor)) { - insertText(text); - return; - } - - const { selection } = editor; - - if (!selection || !Range.isCollapsed(selection)) { - insertText(text); - return; - } - - // block shortcuts - if (blockCommands.some((char) => text.endsWith(char))) { - const endChar = text.slice(-1); - const [match] = Editor.nodes(editor, { - match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorNodeType.Text, - }); - - if (!match) { - insertText(text); - return; - } - - const [, path] = match; - - const { anchor } = selection; - const start = Editor.start(editor, path); - const range = { anchor, focus: start }; - const beforeText = Editor.string(editor, range) + text.slice(0, -1); - - if (beforeText === undefined) { - insertText(text); - return; - } - - const matchItem = matchBlockShortcutType(beforeText, endChar); - - if (matchItem) { - const { type, data } = matchItem; - - Transforms.select(editor, range); - - if (!Range.isCollapsed(range)) { - Transforms.delete(editor); - } - - const newProperties: Partial = { - type, - data, - }; - - CustomEditor.turnToBlock(editor, newProperties); - - return; - } - } - - // inline shortcuts - // end with inline mark char: * or _ or ~ or ` - // eg: **bold** or *italic* or ~strikethrough~ or `code` or _italic_ or __bold__ or ~~strikethrough~~ - const keyword = inlineBlockCommands.find((char) => text.endsWith(char)); - - if (keyword !== undefined) { - const { focus } = selection; - const start = { - path: focus.path, - offset: 0, - }; - const range = { anchor: start, focus }; - - const rangeText = Editor.string(editor, range); - - if (!rangeText.includes(keyword)) { - insertText(text); - return; - } - - const fullText = rangeText + keyword; - - let matchChar = keyword; - - if (doubleCharCommands.includes(keyword)) { - const doubleKeyword = `${keyword}${keyword}`; - - if (rangeText.includes(doubleKeyword)) { - const match = fullText.match(new RegExp(`\\${keyword}{2}(.*)\\${keyword}{2}`)); - - if (!match) { - insertText(text); - return; - } - - matchChar = doubleKeyword; - } - } - - const markType = CharToMarkTypeMap[matchChar]; - - const startIndex = rangeText.lastIndexOf(matchChar); - const beforeText = rangeText.slice(startIndex + matchChar.length, matchChar.length > 1 ? -1 : undefined); - - if (!beforeText) { - insertText(text); - return; - } - - const anchor = { path: start.path, offset: start.offset + startIndex }; - - const at = { - anchor, - focus, - }; - - editor.select(at); - editor.addMark(markType, true); - editor.insertText(beforeText); - editor.collapse({ - edge: 'end', - }); - return; - } - - insertText(text); - }; - - return editor; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts deleted file mode 100644 index 42b37f2a0f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/withShortcuts.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactEditor } from 'slate-react'; -import { withMarkdownShortcuts } from '$app/components/editor/plugins/shortcuts/withMarkdownShortcuts'; - -export function withShortcuts(editor: ReactEditor) { - return withMarkdownShortcuts(editor); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts index 1421a3c93b..62e3ad945a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts @@ -10,6 +10,12 @@ export function getHeadingCssProperty(level: number) { return 'text-2xl pt-[8px] pb-[6px] font-bold'; case 3: return 'text-xl pt-[4px] font-bold'; + case 4: + return 'text-lg pt-[4px] font-bold'; + case 5: + return 'text-base pt-[4px] font-bold'; + case 6: + return 'text-sm pt-[4px] font-bold'; default: return ''; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts index cbb1816db2..b6f8da0e56 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts @@ -1,8 +1,9 @@ import { ReactEditor } from 'slate-react'; import { EditorNodeType } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { Path } from 'slate'; +import { Path, Transforms } from 'slate'; import { YjsEditor } from '@slate-yjs/core'; +import { generateId } from '$app/components/editor/provider/utils/convert'; export function withBlockInsertBreak(editor: ReactEditor) { const { insertBreak } = editor; @@ -16,9 +17,9 @@ export function withBlockInsertBreak(editor: ReactEditor) { const isEmbed = editor.isEmbed(node); - if (isEmbed) { - const nextPath = Path.next(path); + const nextPath = Path.next(path); + if (isEmbed) { CustomEditor.insertEmptyLine(editor as ReactEditor & YjsEditor, nextPath); editor.select(nextPath); return; @@ -26,11 +27,63 @@ export function withBlockInsertBreak(editor: ReactEditor) { const type = node.type as EditorNodeType; + const isBeginning = CustomEditor.focusAtStartOfBlock(editor); + const isEmpty = CustomEditor.isEmptyText(editor, node); - // if the node is empty, convert it to a paragraph - if (isEmpty && type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) { - CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + if (isEmpty) { + const depth = path.length; + let hasNextNode = false; + + try { + hasNextNode = Boolean(editor.node(nextPath)); + } catch (e) { + // do nothing + } + + // if the node is empty and the depth is greater than 1, tab backward + if (depth > 1 && !hasNextNode) { + CustomEditor.tabBackward(editor); + return; + } + + // if the node is empty, convert it to a paragraph + if (type !== EditorNodeType.Paragraph && type !== EditorNodeType.Page) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + } else if (isBeginning) { + // insert line below the current block + const newNodeType = [ + EditorNodeType.TodoListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.NumberedListBlock, + ].includes(type) + ? type + : EditorNodeType.Paragraph; + + Transforms.insertNodes( + editor, + { + type: newNodeType, + data: node.data ?? {}, + blockId: generateId(), + children: [ + { + type: EditorNodeType.Text, + textId: generateId(), + children: [ + { + text: '', + }, + ], + }, + ], + }, + { + at: path, + } + ); return; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx index e93bd1c07b..9bbbae2974 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx @@ -35,9 +35,9 @@ function Breadcrumb() { {pagePath?.map((page: Page, index) => { if (index === pagePath.length - 1) { return ( -
-
{getPageIcon(page)}
- {page.name || t('menuAppHeader.defaultNewPageName')} +
+
{getPageIcon(page)}
+ {page.name.trim() || t('menuAppHeader.defaultNewPageName')}
); } @@ -54,7 +54,7 @@ function Breadcrumb() { >
{getPageIcon(page)}
- {page.name || t('document.title.placeholder')} + {page.name.trim() || t('menuAppHeader.defaultNewPageName')} ); })} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx index 448fdc441a..948aedcae2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/nested_page/NestedPageTitle.tsx @@ -76,7 +76,7 @@ function NestedPageTitle({ {pageIcon}
- {page?.name || t('menuAppHeader.defaultNewPageName')} + {page?.name.trim() || t('menuAppHeader.defaultNewPageName')}
e.stopPropagation()} className={'min:w-14 flex items-center justify-end px-2'}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts index e98a846da0..b6748614b8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts @@ -39,6 +39,9 @@ export function useLoadTrash() { export function useTrashActions() { const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false); const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const [deleteId, setDeleteId] = useState(''); const onClickRestoreAll = () => { setRestoreAllDialogOpen(true); @@ -51,9 +54,18 @@ export function useTrashActions() { const closeDialog = () => { setRestoreAllDialogOpen(false); setDeleteAllDialogOpen(false); + setDeleteDialogOpen(false); + }; + + const onClickDelete = (id: string) => { + setDeleteId(id); + setDeleteDialogOpen(true); }; return { + onClickDelete, + deleteDialogOpen, + deleteId, onPutback: putback, onDelete: deleteTrashItem, onDeleteAll: deleteAll, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx index 40f51d1fbf..f10848dc9b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx @@ -20,6 +20,9 @@ function Trash() { onRestoreAll, onDeleteAll, closeDialog, + deleteDialogOpen, + deleteId, + onClickDelete, } = useTrashActions(); const [hoverId, setHoverId] = useState(''); @@ -50,7 +53,7 @@ function Trash() { item={item} key={item.id} onPutback={onPutback} - onDelete={onDelete} + onDelete={onClickDelete} hoverId={hoverId} setHoverId={setHoverId} /> @@ -62,6 +65,7 @@ function Trash() { subtitle={t('trash.confirmRestoreAll.caption')} onOk={onRestoreAll} onClose={closeDialog} + okText={t('trash.restoreAll')} /> + onDelete([deleteId])} + onClose={closeDialog} + />
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx index 9d4bb15628..d266005612 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx @@ -17,7 +17,7 @@ function TrashItem({ item: Trash; hoverId: string; onPutback: (id: string) => void; - onDelete: (ids: string[]) => void; + onDelete: (id: string) => void; }) { const { t } = useTranslation(); @@ -35,7 +35,9 @@ function TrashItem({ }} >
-
{item.name || t('document.title.placeholder')}
+
+ {item.name.trim() || t('menuAppHeader.defaultNewPageName')} +
{dayjs.unix(item.modifiedTime).format('MM/DD/YYYY hh:mm A')}
{dayjs.unix(item.createTime).format('MM/DD/YYYY hh:mm A')}
- onDelete([item.id])}> + onDelete(item.id)}> diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts index fab7f0612f..9e58429e8d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/hotkeys.ts @@ -22,40 +22,62 @@ export enum HOT_KEY_NAME { UNDERLINE = 'underline', STRIKETHROUGH = 'strikethrough', CODE = 'code', + TOGGLE_TODO = 'toggle-todo', + TOGGLE_COLLAPSE = 'toggle-collapse', } 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', + [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', 'mod+shift+x'], + [HOT_KEY_NAME.CODE]: ['mod+e'], + [HOT_KEY_NAME.TOGGLE_TODO]: ['mod+enter'], + [HOT_KEY_NAME.TOGGLE_COLLAPSE]: ['mod+enter'], }; const replaceModifier = (hotkey: string) => { return hotkey.replace('mod', getModifier()).replace('control', 'ctrl'); }; -export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { +/** + * Create a hotkey checker. + * @example trigger strike through when user press "Cmd + Shift + S" or "Cmd + Shift + X" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotkey = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { const keys = customHotKeys || defaultHotKeys; - const hotkey = keys[hotkeyName]; + const hotkeys = keys[hotkeyName]; return (event: KeyboardEvent) => { - return isHotkey(hotkey, event); + return hotkeys.some((hotkey) => { + return isHotkey(hotkey, event); + }); }; }; -export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { +/** + * Create a hotkey label. + * eg. "Ctrl + B / ⌘ + B" + * @param hotkeyName + * @param customHotKeys + */ +export const createHotKeyLabel = (hotkeyName: HOT_KEY_NAME, customHotKeys?: Record) => { const keys = customHotKeys || defaultHotKeys; - const hotkey = replaceModifier(keys[hotkeyName]); + const hotkeys = keys[hotkeyName].map((key) => replaceModifier(key)); - return hotkey - .split('+') - .map((key) => { - return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1); - }) - .join(' + '); + return hotkeys + .map((hotkey) => + hotkey + .split('+') + .map((key) => { + return key === ' ' ? 'Space' : key.charAt(0).toUpperCase() + key.slice(1); + }) + .join(' + ') + ) + .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 3fd9933a45..d854be5211 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -1,9 +1,14 @@ import { open as openWindow } from '@tauri-apps/api/shell'; -export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/; +const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\S*)*\/?(\?[=&\w.%-]*)?(#[\w.\-!~*'()]*)?$/; +const ipPattern = /^(https?:\/\/)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d{1,5})?$/; + +export function isUrl(str: string) { + return urlPattern.test(str) || ipPattern.test(str); +} export function openUrl(str: string) { - if (pattern.test(str)) { + if (isUrl(str)) { const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; if (linkPrefix.some((prefix) => str.startsWith(prefix))) { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index cd703d4a56..8807a3a057 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -144,7 +144,8 @@ "emptyDescription": "You don't have any deleted file", "isDeleted": "is deleted", "isRestored": "is restored" - } + }, + "confirmDeleteTitle": "Are you sure you want to delete this page permanently?" }, "deletePagePrompt": { "text": "This page is in Trash", diff --git a/frontend/rust-lib/flowy-document/src/parser/constant.rs b/frontend/rust-lib/flowy-document/src/parser/constant.rs index 27e817114d..20edb93871 100644 --- a/frontend/rust-lib/flowy-document/src/parser/constant.rs +++ b/frontend/rust-lib/flowy-document/src/parser/constant.rs @@ -107,7 +107,6 @@ pub const TEXT_DECORATION: &str = "text-decoration"; pub const BACKGROUND_COLOR: &str = "background-color"; pub const TRANSPARENT: &str = "transparent"; -pub const DEFAULT_FONT_COLOR: &str = "rgb(0, 0, 0)"; pub const COLOR: &str = "color"; pub const LINE_THROUGH: &str = "line-through"; diff --git a/frontend/rust-lib/flowy-document/src/parser/external/utils.rs b/frontend/rust-lib/flowy-document/src/parser/external/utils.rs index 257f81e772..1e31792f2f 100644 --- a/frontend/rust-lib/flowy-document/src/parser/external/utils.rs +++ b/frontend/rust-lib/flowy-document/src/parser/external/utils.rs @@ -428,10 +428,6 @@ fn get_attributes_with_style(style: &str) -> HashMap { attributes.insert(BG_COLOR.to_string(), Value::String(value.to_string())); }, COLOR => { - if value.eq(DEFAULT_FONT_COLOR) { - continue; - } - attributes.insert(FONT_COLOR.to_string(), Value::String(value.to_string())); }, _ => {}, diff --git a/frontend/rust-lib/flowy-document/tests/assets/json/simple.json b/frontend/rust-lib/flowy-document/tests/assets/json/simple.json index 9a27d97913..2ab6a3275e 100644 --- a/frontend/rust-lib/flowy-document/tests/assets/json/simple.json +++ b/frontend/rust-lib/flowy-document/tests/assets/json/simple.json @@ -2,7 +2,10 @@ "type": "page", "data": { "delta": [{ - "insert": "This is a paragraph" + "insert": "This is a paragraph", + "attributes": { + "font_color": "rgb(0, 0, 0)" + } }] }, "children": []