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 bdc4b23600..e6eb1d6923 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 @@ -74,6 +74,9 @@ export interface QuoteNode extends Element { export interface NumberedListNode extends Element { type: EditorNodeType.NumberedListBlock; blockId: string; + data: { + number?: number; + } & BlockData; } export interface BulletedListNode extends Element { 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 c6c17a1008..3fa48ffa5a 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 @@ -76,13 +76,20 @@ export const CustomEditor = { return CustomEditor.isInlineNode(editor, afterPoint); }, - isMultipleBlockSelected: (editor: ReactEditor, filterEmpty = false) => { + /** + * judge if the selection is multiple block + * @param editor + * @param filterEmptyEndSelection if the filterEmptyEndSelection is true, the function will filter the empty end selection + */ + isMultipleBlockSelected: (editor: ReactEditor, filterEmptyEndSelection?: boolean): boolean => { const { selection } = editor; if (!selection) return false; - const start = selection.anchor; - const end = selection.focus; + if (Range.isCollapsed(selection)) return false; + const start = Range.start(selection); + const end = Range.end(selection); + const isBackward = Range.isBackward(selection); const startBlock = CustomEditor.getBlock(editor, start); const endBlock = CustomEditor.getBlock(editor, end); @@ -90,30 +97,44 @@ export const CustomEditor = { const [, startPath] = startBlock; const [, endPath] = endBlock; - const pathIsEqual = Path.equals(startPath, endPath); - if (pathIsEqual) { + const isSomePath = Path.equals(startPath, endPath); + + // if the start and end path is the same, return false + if (isSomePath) { return false; } - if (!filterEmpty) { + if (!filterEmptyEndSelection) { return true; } - const notEmptyBlocks = Array.from( - editor.nodes({ - match: (n) => { - return ( - !Editor.isEditor(n) && - Element.isElement(n) && - n.blockId !== undefined && - !CustomEditor.isEmptyText(editor, n) - ); - }, - }) - ); + // The end point is at the start of the end block + const focusEndStart = Point.equals(end, editor.start(endPath)); - return notEmptyBlocks.length > 1; + if (!focusEndStart) { + return true; + } + + // find the previous block + const previous = editor.previous({ + at: endPath, + match: (n) => Element.isElement(n) && n.blockId !== undefined, + }); + + if (!previous) { + return true; + } + + // backward selection + const newEnd = editor.end(editor.range(previous[1])); + + editor.select({ + anchor: isBackward ? newEnd : start, + focus: isBackward ? start : newEnd, + }); + + return false; }, /** @@ -625,4 +646,28 @@ export const CustomEditor = { isEmbedNode(node: Element): boolean { return EmbedTypes.includes(node.type); }, + + getListLevel(editor: ReactEditor, type: EditorNodeType, path: Path) { + let level = 0; + let currentPath = path; + + while (currentPath.length > 0) { + const parent = editor.parent(currentPath); + + if (!parent) { + break; + } + + const [parentNode, parentPath] = parent as NodeEntry; + + if (parentNode.type !== type) { + break; + } + + level += 1; + currentPath = parentPath; + } + + return level; + }, }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx index 0e712b3269..ea0de80f55 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedListIcon.tsx @@ -1,14 +1,49 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { BulletedListNode } from '$app/application/document/document.types'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Disc, + Circle, + Square, +} + +function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) { + const staticEditor = useSlateStatic(); + const path = ReactEditor.findPath(staticEditor, block); + + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Disc; + } else if (level % 3 === 1) { + return Letter.Circle; + } else { + return Letter.Square; + } + }, [block.type, staticEditor, path]); + + const dataLetter = useMemo(() => { + switch (letter) { + case Letter.Disc: + return '•'; + case Letter.Circle: + return '◦'; + case Letter.Square: + return '▪'; + } + }, [letter]); -function BulletedListIcon({ block: _, className }: { block: BulletedListNode; className: string }) { return ( { e.preventDefault(); }} + data-letter={dataLetter} contentEditable={false} - className={`${className} bulleted-icon flex min-w-[23px] justify-center pr-1 font-medium`} + className={`${className} bulleted-icon flex min-w-[24px] justify-center pr-1 font-medium`} /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx index c0ee4f3ead..888b46c980 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberListIcon.tsx @@ -1,15 +1,35 @@ import React, { useMemo } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; +import { ReactEditor, useSlate, useSlateStatic } from 'slate-react'; import { Element, Path } from 'slate'; import { NumberedListNode } from '$app/application/document/document.types'; +import { letterize, romanize } from '$app/utils/list'; +import { CustomEditor } from '$app/components/editor/command'; + +enum Letter { + Number = 'number', + Letter = 'letter', + Roman = 'roman', +} + +function getLetterNumber(index: number, letter: Letter) { + if (letter === Letter.Number) { + return index; + } else if (letter === Letter.Letter) { + return letterize(index); + } else { + return romanize(index); + } +} function NumberListIcon({ block, className }: { block: NumberedListNode; className: string }) { const editor = useSlate(); + const staticEditor = useSlateStatic(); const path = ReactEditor.findPath(editor, block); const index = useMemo(() => { let index = 1; + let topNode; let prevPath = Path.previous(path); while (prevPath) { @@ -19,6 +39,7 @@ function NumberListIcon({ block, className }: { block: NumberedListNode; classNa if (prevNode.type === block.type) { index += 1; + topNode = prevNode; } else { break; } @@ -26,17 +47,39 @@ function NumberListIcon({ block, className }: { block: NumberedListNode; classNa prevPath = Path.previous(prevPath); } - return index; + if (!topNode) { + return Number(block.data?.number ?? 1); + } + + const startIndex = (topNode as NumberedListNode).data?.number ?? 1; + + return index + Number(startIndex) - 1; }, [editor, block, path]); + const letter = useMemo(() => { + const level = CustomEditor.getListLevel(staticEditor, block.type, path); + + if (level % 3 === 0) { + return Letter.Number; + } else if (level % 3 === 1) { + return Letter.Letter; + } else { + return Letter.Roman; + } + }, [block.type, staticEditor, path]); + + const dataNumber = useMemo(() => { + return getLetterNumber(index, letter); + }, [index, letter]); + return ( { e.preventDefault(); }} contentEditable={false} - data-number={index} - className={`${className} numbered-icon flex w-[23px] min-w-[23px] justify-center pr-1 font-medium`} + data-number={dataNumber} + className={`${className} numbered-icon flex w-[24px] min-w-[24px] justify-center pr-1 font-medium`} /> ); } 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 00ad26b6d9..55edd5bbd2 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,14 +14,14 @@ export const Text = memo( {renderIcon()} - {children} + {children} ); }) 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 e70c22d36f..5d83870719 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 @@ -1,51 +1,26 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useSlate } from 'slate-react'; import { MentionPage, MentionType } from '$app/application/document/document.types'; import { CustomEditor } from '$app/components/editor/command'; -import { useAppSelector } from '$app/stores/store'; import { KeyboardNavigationOption } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +// import dayjs from 'dayjs'; +// enum DateKey { +// Today = 'today', +// Tomorrow = 'tomorrow', +// } export function useMentionPanel({ closePanel, - searchText, + pages, }: { - searchText: string; + pages: MentionPage[]; closePanel: (deleteText?: boolean) => void; }) { const { t } = useTranslation(); const editor = useSlate(); - const pagesMap = useAppSelector((state) => state.pages.pageMap); - - const pagesRef = useRef([]); - const [recentPages, setPages] = useState([]); - - const loadPages = useCallback(async () => { - const pages = Object.values(pagesMap); - - pagesRef.current = pages; - setPages(pages); - }, [pagesMap]); - - useEffect(() => { - void loadPages(); - }, [loadPages]); - - useEffect(() => { - if (!searchText) { - setPages(pagesRef.current); - return; - } - - const filteredPages = pagesRef.current.filter((page) => { - return page.name.toLowerCase().includes(searchText.toLowerCase()); - }); - - setPages(filteredPages); - }, [searchText]); - const onConfirm = useCallback( (key: string) => { const [, id] = key.split(','); @@ -75,15 +50,62 @@ export function useMentionPanel({ [t] ); + // const renderDate = useCallback(() => { + // return [ + // { + // key: DateKey.Today, + // content: ( + //
+ // {t('relativeDates.today')} -{' '} + // {dayjs().format('MMM D, YYYY')} + //
+ // ), + // + // children: [], + // }, + // { + // key: DateKey.Tomorrow, + // content: ( + //
+ // {t('relativeDates.tomorrow')} + //
+ // ), + // children: [], + // }, + // ]; + // }, [t]); + const options: KeyboardNavigationOption[] = useMemo(() => { return [ + // { + // key: MentionType.Date, + // content:
{t('editor.date')}
, + // children: renderDate(), + // }, + { + key: 'divider', + content:
, + children: [], + }, + { key: MentionType.PageRef, content:
{t('document.mention.page.label')}
, - children: recentPages.map(renderPage), + children: + pages.length > 0 + ? pages.map(renderPage) + : [ + { + key: 'noPage', + content: ( +
{t('findAndReplace.noResult')}
+ ), + children: [], + }, + ], }, - ].filter((option) => option.children.length > 0); - }, [recentPages, renderPage, t]); + ]; + }, [pages, renderPage, t]); return { options, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx index eba84e4ac4..6ca0225579 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { initialAnchorOrigin, initialTransformOrigin, @@ -9,10 +9,39 @@ import Popover from '@mui/material/Popover'; import MentionPanelContent from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent'; import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { useAppSelector } from '$app/stores/store'; +import { MentionPage } from '$app/application/document/document.types'; export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelProps) { const ref = useRef(null); + const pagesMap = useAppSelector((state) => state.pages.pageMap); + const pagesRef = useRef([]); + const [recentPages, setPages] = useState([]); + + const loadPages = useCallback(async () => { + const pages = Object.values(pagesMap); + + pagesRef.current = pages; + setPages(pages); + }, [pagesMap]); + + useEffect(() => { + void loadPages(); + }, [loadPages]); + + useEffect(() => { + if (!searchText) { + setPages(pagesRef.current); + return; + } + + const filteredPages = pagesRef.current.filter((page) => { + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + + setPages(filteredPages); + }, [searchText]); const open = Boolean(anchorPosition); const { @@ -42,12 +71,7 @@ export function MentionPanel({ anchorPosition, closePanel, searchText }: PanelPr transformOrigin={transformOrigin} onClose={() => closePanel(false)} > - + )}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx index 9664479fbd..36b00ca2b6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx @@ -2,15 +2,16 @@ import React, { useRef } from 'react'; import { useMentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks'; import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { MentionPage } from '$app/application/document/document.types'; function MentionPanelContent({ closePanel, - searchText, + pages, maxHeight, width, }: { closePanel: (deleteText?: boolean) => void; - searchText: string; + pages: MentionPage[]; maxHeight: number; width: number; }) { @@ -18,7 +19,7 @@ function MentionPanelContent({ const { options, onConfirm } = useMentionPanel({ closePanel, - searchText, + pages, }); return ( 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 ddab776abc..19ca3aebf1 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 @@ -157,7 +157,7 @@ export function useSlashCommandPanel({ if (!newNode || !path) return; - const isEmpty = CustomEditor.isEmptyText(editor, newNode) && node.type === EditorNodeType.Paragraph; + const isEmpty = CustomEditor.isEmptyText(editor, newNode); if (!isEmpty) { const nextPath = Path.next(path); 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 773372a8bc..58834db6d5 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 @@ -169,6 +169,7 @@ export function useSelectionToolbar(ref: MutableRefObject }; }, [visible, editor, ref]); + // Close toolbar when press ESC useEffect(() => { const slateEditorDom = ReactEditor.toDOMNode(editor, editor); const onKeyDown = (e: KeyboardEvent) => { @@ -195,6 +196,39 @@ export function useSelectionToolbar(ref: MutableRefObject }; }, [closeToolbar, debounceRecalculatePosition, editor, visible]); + // Recalculate position when the scroll container is scrolled + useEffect(() => { + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + const scrollContainer = slateEditorDom.closest('.appflowy-scroll-container'); + + if (!visible) return; + if (!scrollContainer) return; + const handleScroll = () => { + if (isDraggingRef.current) return; + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rangeRect = domRange?.getBoundingClientRect(); + + // Stop calculating when the range is out of the window + if (!rangeRect?.bottom || rangeRect.bottom < 0) { + return; + } + + recalculatePosition(); + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [visible, editor, recalculatePosition]); + return { visible, restoreSelection, 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 7096955296..0ddc89666d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss @@ -9,8 +9,6 @@ margin-left: 24px; } - - .block-element.block-align-left { > div > .text-element { text-align: left; @@ -50,6 +48,8 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { + + [role="textbox"] { ::selection { @apply bg-content-blue-100; @@ -58,6 +58,9 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &::selection { @apply bg-transparent; } + &.selected { + @apply bg-content-blue-100; + } span { &::selection { @apply bg-content-blue-100; @@ -67,7 +70,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } - [data-dark-mode="true"] [role="textbox"]{ ::selection { background-color: #1e79a2; @@ -77,6 +79,9 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { &::selection { @apply bg-transparent; } + &.selected { + background-color: #1e79a2; + } span { &::selection { background-color: #1e79a2; @@ -85,10 +90,9 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } } - .text-content, [data-dark-mode="true"] .text-content { @apply min-w-[1px]; - &.empty-content { + &.empty-text { span { &::selection { @apply bg-transparent; @@ -113,7 +117,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .has-start-icon > .text-placeholder { &:after { - @apply left-[30px]; + @apply left-[29px]; } } @@ -125,7 +129,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { .bulleted-icon { &:after { - content: "•"; + content: attr(data-letter); } } 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 index 1a78bdc8cc..51115986be 100644 --- 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 @@ -1,4 +1,4 @@ -import { Range, Element, Editor, NodeEntry } from 'slate'; +import { Range, Element, Editor, NodeEntry, Path } from 'slate'; import { ReactEditor } from 'slate-react'; import { getRegex, @@ -29,6 +29,10 @@ export const withMarkdown = (editor: ReactEditor) => { const match = CustomEditor.getBlock(editor); const [node, path] = match as NodeEntry; + const prevPath = Path.previous(path); + const prev = editor.node(prevPath) as NodeEntry; + const prevIsNumberedList = prev && prev[0].type === EditorNodeType.NumberedListBlock; + const start = Editor.start(editor, path); const beforeRange = { anchor: start, focus: selection.anchor }; const beforeText = Editor.string(editor, beforeRange); @@ -59,6 +63,11 @@ export const withMarkdown = (editor: ReactEditor) => { return; } + // 3. If the block is number list, and the previous block is also number list + if (block.type === EditorNodeType.NumberedListBlock && prevIsNumberedList) { + return; + } + removeBeforeText(beforeRange); CustomEditor.turnToBlock(editor, block); @@ -145,7 +154,9 @@ function whichBlock(shortcut: MarkdownShortcuts, beforeText: string) { case MarkdownShortcuts.NumberedList: return { type: EditorNodeType.NumberedListBlock, - data: {}, + data: { + number: Number(beforeText.split('.')[0]) ?? 1, + }, }; case MarkdownShortcuts.TodoList: return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts new file mode 100644 index 0000000000..6e5d22ccda --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/list.ts @@ -0,0 +1,45 @@ +const romanMap: [number, string][] = [ + [1000, 'M'], + [900, 'CM'], + [500, 'D'], + [400, 'CD'], + [100, 'C'], + [90, 'XC'], + [50, 'L'], + [40, 'XL'], + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], +]; + +export function romanize(num: number): string { + let result = ''; + let nextNum = num; + + for (const [value, symbol] of romanMap) { + const count = Math.floor(nextNum / value); + + nextNum -= value * count; + result += symbol.repeat(count); + if (nextNum === 0) break; + } + + return result; +} + +export function letterize(num: number): string { + let nextNum = num; + let letters = ''; + + while (nextNum > 0) { + nextNum--; + const letter = String.fromCharCode((nextNum % 26) + 'a'.charCodeAt(0)); + + letters = letter + letters; + nextNum = Math.floor(nextNum / 26); + } + + return letters; +}