feat: support list item style (#4934)

* fix: wrong offset of mention panel

feat: support list item style

* feat: support backward selection when the end selection is empty
This commit is contained in:
Kilu.He 2024-03-20 18:29:13 +08:00 committed by GitHub
parent b2fb631500
commit 73df51f35f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 353 additions and 86 deletions

View File

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

View File

@ -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<Element>;
if (parentNode.type !== type) {
break;
}
level += 1;
currentPath = parentPath;
}
return level;
},
};

View File

@ -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 (
<span
onMouseDown={(e) => {
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`}
/>
);
}

View File

@ -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 (
<span
onMouseDown={(e) => {
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`}
/>
);
}

View File

@ -14,14 +14,14 @@ export const Text = memo(
<span
ref={ref}
{...attributes}
className={`text-element relative my-1 flex w-full whitespace-pre-wrap px-1 ${isEmpty ? 'select-none' : ''} ${
className ?? ''
} ${hasStartIcon ? 'has-start-icon' : ''}`}
className={`text-element relative my-1 flex w-full whitespace-pre-wrap break-words px-1 ${className ?? ''} ${
hasStartIcon ? 'has-start-icon' : ''
}`}
>
{renderIcon()}
<Placeholder isEmpty={isEmpty} node={node} />
<span className={`text-content ${isEmpty ? 'empty-content' : ''}`}>{children}</span>
<span className={`text-content ${isEmpty ? 'empty-text' : ''}`}>{children}</span>
</span>
);
})

View File

@ -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<MentionPage[]>([]);
const [recentPages, setPages] = useState<MentionPage[]>([]);
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: (
// <div className={'px-3 pb-1 pt-2 text-xs '}>
// <span className={'text-text-title'}>{t('relativeDates.today')}</span> -{' '}
// <span className={'text-xs text-text-caption'}>{dayjs().format('MMM D, YYYY')}</span>
// </div>
// ),
//
// children: [],
// },
// {
// key: DateKey.Tomorrow,
// content: (
// <div className={'px-3 pb-1 pt-2 text-xs '}>
// <span className={'text-text-title'}>{t('relativeDates.tomorrow')}</span>
// </div>
// ),
// children: [],
// },
// ];
// }, [t]);
const options: KeyboardNavigationOption<MentionType | string>[] = useMemo(() => {
return [
// {
// key: MentionType.Date,
// content: <div className={'px-3 pb-1 pt-2 text-sm'}>{t('editor.date')}</div>,
// children: renderDate(),
// },
{
key: 'divider',
content: <div className={'border-t border-line-divider'} />,
children: [],
},
{
key: MentionType.PageRef,
content: <div className={'px-3 pb-1 pt-2 text-sm'}>{t('document.mention.page.label')}</div>,
children: recentPages.map(renderPage),
children:
pages.length > 0
? pages.map(renderPage)
: [
{
key: 'noPage',
content: (
<div className={'px-3 pb-3 pt-2 text-xs text-text-caption'}>{t('findAndReplace.noResult')}</div>
),
children: [],
},
],
},
].filter((option) => option.children.length > 0);
}, [recentPages, renderPage, t]);
];
}, [pages, renderPage, t]);
return {
options,

View File

@ -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<HTMLDivElement>(null);
const pagesMap = useAppSelector((state) => state.pages.pageMap);
const pagesRef = useRef<MentionPage[]>([]);
const [recentPages, setPages] = useState<MentionPage[]>([]);
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)}
>
<MentionPanelContent
width={paperWidth}
maxHeight={paperHeight}
closePanel={closePanel}
searchText={searchText}
/>
<MentionPanelContent width={paperWidth} maxHeight={paperHeight} closePanel={closePanel} pages={recentPages} />
</Popover>
)}
</div>

View File

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

View File

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

View File

@ -169,6 +169,7 @@ export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>
};
}, [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<HTMLDivElement | null>
};
}, [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,

View File

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

View File

@ -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<Element>;
const prevPath = Path.previous(path);
const prev = editor.node(prevPath) as NodeEntry<Element>;
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 {

View File

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