feat: support toggle heading (#6712)

* feat: support toggle heading

* fix: support others markdown
This commit is contained in:
Kilu.He 2024-11-05 14:06:38 +08:00 committed by GitHub
parent 82effbf8e4
commit f6e002edbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 406 additions and 41 deletions

View File

@ -149,7 +149,7 @@ export const CustomEditor = {
const blockType = block.get(YjsEditorKey.block_type) as BlockType;
if (blockType !== BlockType.Paragraph) {
handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block);
handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point);
return;
}

View File

@ -72,7 +72,7 @@ function insertText (ydoc: Y.Doc, editor: Editor, { path, offset, text, attribut
console.log('beforeAttributes', relativeOffset, beforeAttributes);
if (beforeAttributes && ('formula' in beforeAttributes || 'mention' in beforeAttributes)) {
if (beforeAttributes && ('formula' in beforeAttributes || 'mention' in beforeAttributes || 'href' in beforeAttributes)) {
const newAttributes = {
...attributes,
};
@ -89,6 +89,12 @@ function insertText (ydoc: Y.Doc, editor: Editor, { path, offset, text, attribut
});
}
if ('href' in beforeAttributes) {
Object.assign({
href: null,
});
}
yText.insert(relativeOffset, text, newAttributes);
} else {
yText.insert(relativeOffset, text, attributes);

View File

@ -16,9 +16,9 @@ import {
import { nanoid } from 'nanoid';
import Delta, { Op } from 'quill-delta';
import {
BasePoint,
BaseRange,
Descendant,
Text,
Editor,
Element,
Node,
@ -26,8 +26,8 @@ import {
Path,
Point,
Range,
Text,
Transforms,
BasePoint,
} from 'slate';
import { ReactEditor } from 'slate-react';
import * as Y from 'yjs';
@ -180,13 +180,13 @@ export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSha
const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot);
if (yText.length === 0) {
const point = Editor.start(editor, at);
if (blockType !== BlockType.Paragraph) {
handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block);
handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point);
return;
}
const point = Editor.start(editor, at);
if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) {
return;
}
@ -293,6 +293,46 @@ export function turnToBlock<T extends BlockData> (sharedRoot: YSharedRoot, sourc
// delete source block
deleteBlock(sharedRoot, sourceBlock.get(YjsEditorKey.block_id));
// turn to toggle heading
if (type === BlockType.ToggleListBlock && (data as unknown as ToggleListBlockData).level) {
const nextSiblings = getNextSiblings(sharedRoot, newBlock);
if (!nextSiblings || nextSiblings.length === 0) return;
// find the next sibling with the same or higher level
const index = nextSiblings.findIndex((id) => {
const block = getBlock(id, sharedRoot);
const blockData = dataStringTOJson(block.get(YjsEditorKey.block_data));
if ('level' in blockData && (blockData as {
level: number
}).level <= ((data as unknown as ToggleListBlockData).level as number)) {
return true;
}
return false;
});
const nodes = index > -1 ? nextSiblings.slice(0, index) : nextSiblings;
// if not found, return. Otherwise, indent the block
nodes.forEach((id) => {
const block = getBlock(id, sharedRoot);
indentBlock(sharedRoot, block);
});
}
}
function getNextSiblings (sharedRoot: YSharedRoot, block: YBlock) {
const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot);
if (!parent) return;
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot);
const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id));
return parentChildren.toArray().slice(index + 1);
}
function getSplitBlockOperations (sharedRoot: YSharedRoot, block: YBlock, offset: number): {
@ -822,10 +862,26 @@ export function getBlockEntry (editor: YjsEditor, point?: Point) {
return blockEntry as NodeEntry<Element>;
}
export function handleNonParagraphBlockBackspaceAndEnterWithTxn (sharedRoot: YSharedRoot, block: YBlock) {
export function handleNonParagraphBlockBackspaceAndEnterWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: BasePoint) {
const data = dataStringTOJson(block.get(YjsEditorKey.block_data));
const blockType = block.get(YjsEditorKey.block_type);
if (blockType === BlockType.ToggleListBlock && (data as ToggleListBlockData).level) {
const [, path] = getBlockEntry(editor, point);
Transforms.setNodes(editor, {
data: {
...data,
level: null,
},
}, { at: path });
return;
}
const operations: (() => void)[] = [];
operations.push(() => {
turnToBlock(sharedRoot, block, BlockType.Paragraph, {});
});
executeOperations(sharedRoot, operations, 'turnToBlock');

View File

@ -41,7 +41,7 @@ describe('Markdown editing', () => {
cy.get('@editor').type('##');
cy.get('@editor').realPress('Space');
cy.wait(50);
cy.get('@editor').type('Heading 2');
expectedJson = [...expectedJson, {
type: 'heading',
@ -436,11 +436,152 @@ describe('Markdown editing', () => {
},
];
assertJSON(expectedJson);
// Test 7: Toggle heading
cy.get('@editor').realPress('Enter');
cy.get('@editor').type('>');
cy.get('@editor').realPress('Space');
cy.get('@editor').type('toggle heading');
cy.get('@editor').realPress('Enter');
cy.get('@editor').type('toggle heading child');
cy.get('@editor').realPress('Enter');
cy.get('@editor').realPress(['Shift', 'Tab']);
cy.get('@editor').type('toggle heading sibling');
cy.get('@editor').realPress('Enter');
cy.get('@editor').type('###');
cy.get('@editor').realPress('Space');
cy.get('@editor').type('heading 3');
cy.get('@editor').selectMultipleText(['toggle heading']);
cy.wait(500);
cy.get('@editor').realPress(['ArrowLeft']);
cy.get('@editor').type('#');
cy.get('@editor').realPress('Space');
const extraData: FromBlockJSON[] = [{
type: 'toggle_list',
data: {
level: 1,
collapsed: false,
},
text: [{
insert: 'toggle heading',
}],
children: [{
type: 'paragraph',
data: {},
text: [{
insert: 'toggle heading child',
}],
children: [],
}],
},
{
type: 'paragraph',
data: {},
text: [{
insert: 'toggle heading sibling',
}],
children: [],
},
{
type: 'heading',
data: {
level: 3,
},
text: [{
insert: 'heading 3',
}],
children: [],
}];
assertJSON([
...expectedJson,
...extraData,
]);
cy.get('@editor').realPress('Backspace');
assertJSON([
...expectedJson,
{
...extraData[0],
data: {
collapsed: false,
level: null,
},
},
extraData[1],
extraData[2],
] as FromBlockJSON[]);
cy.get('@editor').realPress('Backspace');
assertJSON([
...expectedJson,
{
...extraData[0],
type: 'paragraph',
data: {},
},
extraData[1],
extraData[2],
] as FromBlockJSON[]);
cy.get('@editor').type('#');
cy.get('@editor').realPress('Space');
cy.get('@editor').type('>');
cy.get('@editor').realPress('Space');
expectedJson = [
...expectedJson,
{
...extraData[0],
children: [
extraData[0].children[0],
extraData[1],
extraData[2],
],
},
] as FromBlockJSON[];
assertJSON(expectedJson);
cy.selectMultipleText(['heading 3']);
cy.wait(500);
cy.get('@editor').realPress('ArrowRight');
cy.get('@editor').realPress('Enter');
cy.get('@editor').realPress(['Shift', 'Tab']);
// Test 8: Link
cy.get('@editor').type('Link: [Click here](https://example.com');
cy.get('@editor').realPress(')');
assertJSON([
...expectedJson,
{
type: 'paragraph',
data: {},
text: [{ insert: 'Link: ' }, {
insert: 'Click here',
attributes: { href: 'https://example.com' },
}],
children: [],
},
]);
cy.get('@editor').type('link anchor');
expectedJson = [
...expectedJson,
{
type: 'paragraph',
data: {},
text: [{ insert: 'Link: ' }, {
insert: 'Click here',
attributes: { href: 'https://example.com' },
}, { insert: 'link anchor' }],
children: [],
},
];
assertJSON(expectedJson);
cy.get('@editor').realPress('Enter');
//
// Last test: Divider
cy.get('@editor').type('--');
cy.get('@editor').realPress('-');
expectedJson = [
...expectedJson.slice(0, -1),
...expectedJson,
{
type: 'divider',
data: {},

View File

@ -35,7 +35,7 @@ function ToggleIcon ({ block, className }: { block: ToggleListNode; className: s
onMouseDown={(e) => {
e.preventDefault();
}}
className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 text-xl`}
className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 text-xl h-full`}
>
{collapsed ? <ExpandSvg className={'-rotate-90 transform'} /> : <ExpandSvg />}
</span>

View File

@ -1,17 +1,35 @@
import { getHeadingCssProperty } from '@/components/editor/components/blocks/heading';
import React, { forwardRef, memo, useMemo } from 'react';
import { EditorElementProps, ToggleListNode } from '@/components/editor/editor.type';
export const ToggleList = memo(
forwardRef<HTMLDivElement, EditorElementProps<ToggleListNode>>(({ node, children, ...attributes }, ref) => {
const { collapsed, level } = useMemo(() => node.data || {}, [node.data]);
const fontSizeCssProperty = getHeadingCssProperty(level || 0);
const className = `${attributes.className ?? ''} flex w-full flex-col ${collapsed ? 'collapsed' : ''} ${fontSizeCssProperty} level-${level}`;
const { collapsed, level = 0 } = useMemo(() => node.data || {}, [node.data]);
const className = useMemo(() => {
const classList = ['flex w-full flex-col'];
if (attributes.className) {
classList.push(attributes.className);
}
if (collapsed) {
classList.push('collapsed');
}
if (level) {
classList.push(`toggle-heading level-${level}`);
}
return classList.join(' ');
}, [collapsed, level, attributes.className]);
return (
<>
<div {...attributes} ref={ref}
className={className}
<div
{...attributes}
ref={ref}
className={className}
>
{children}
</div>

View File

@ -344,4 +344,42 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
50% {
background-color: var(--content-blue-100);
}
}
.toggle-heading {
&.level-1 {
> .text-element {
@apply text-[1.75rem] max-md:text-[24px] pt-[10px] max-md:pt-[1.5vw] pb-[4px] max-md:pb-[1vw] font-bold;
}
}
&.level-2 {
> .text-element {
@apply text-[1.55rem] max-md:text-[22px] pt-[8px] max-md:pt-[1vw] pb-[2px] max-md:pb-[0.5vw] font-bold;
}
}
&.level-3 {
> .text-element {
@apply text-[1.35rem] max-md:text-[20px] pt-[4px] font-bold;
}
}
&.level-4 {
> .text-element {
@apply text-[1.25rem] max-md:text-[16px] pt-[4px] font-bold;
}
}
&.level-5 {
> .text-element {
@apply text-[1.15rem] pt-[4px] font-bold;
}
}
&.level-6 {
> .text-element {
@apply text-[1.05rem] pt-[4px] font-bold;
}
}
}

View File

@ -22,7 +22,7 @@ export const withInsertText = (editor: ReactEditor) => {
const [textNode] = textEntry as NodeEntry<Text>;
// If the text node is a formula or mention, split the node and insert the text
if (textNode.formula || textNode.mention) {
if (textNode.formula || textNode.mention || textNode.href) {
console.log('Inserting text into formula or mention', newAt);
Transforms.insertNodes(editor, { text }, { at: point, select: true, voids: false });

View File

@ -3,6 +3,7 @@ import { CustomEditor } from '@/application/slate-yjs/command';
import { EditorMarkFormat } from '@/application/slate-yjs/types';
import { getBlock, getBlockEntry, getSharedRoot, getText } from '@/application/slate-yjs/utils/yjsOperations';
import {
BlockData,
BlockType,
HeadingBlockData,
NumberedListBlockData,
@ -12,8 +13,13 @@ import {
} from '@/application/types';
import { Editor, Range, Transforms } from 'slate';
enum SpecialSymbol {
EM_DASH = '—',
RIGHTWARDS_DOUBLE_ARROW = '⇒',
}
type TriggerHotKey = {
[key in BlockType | EditorMarkFormat]?: string[];
[key in BlockType | EditorMarkFormat | SpecialSymbol]?: string[];
};
const defaultTriggerChar: TriggerHotKey = {
@ -30,6 +36,9 @@ const defaultTriggerChar: TriggerHotKey = {
[EditorMarkFormat.StrikeThrough]: ['~'],
[EditorMarkFormat.Code]: ['`'],
[EditorMarkFormat.Formula]: ['$'],
[EditorMarkFormat.Href]: [')'],
[SpecialSymbol.EM_DASH]: ['-'],
[SpecialSymbol.RIGHTWARDS_DOUBLE_ARROW]: ['>'],
};
// create a set of all trigger characters
@ -37,7 +46,7 @@ export const allTriggerChars = new Set(Object.values(defaultTriggerChar).flat())
// Define the rules for markdown shortcuts
type Rule = {
type: 'block' | 'mark'
type: 'block' | 'mark' | 'symbol';
match: RegExp
format: string
transform?: (editor: YjsEditor, match: RegExpMatchArray) => void
@ -66,16 +75,16 @@ function getNodeType (editor: YjsEditor) {
function getBlockData (editor: YjsEditor) {
const [node] = getBlockEntry(editor);
return node.data;
return node.data as BlockData;
}
function isEmptyLine (editor: YjsEditor, offset: number) {
function getLineText (editor: YjsEditor) {
const [node] = getBlockEntry(editor);
const sharedRoot = getSharedRoot(editor);
const block = getBlock(node.blockId as string, sharedRoot);
const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot);
return yText.toJSON().length === offset;
return yText.toJSON();
}
const rules: Rule[] = [
@ -94,11 +103,42 @@ const rules: Rule[] = [
transform: (editor, match) => {
const level = match[1].length;
const [node] = getBlockEntry(editor);
const blockType = getNodeType(editor);
// If the current block is a toggle list block, we don't need to change the block type
if (blockType === BlockType.ToggleListBlock) {
CustomEditor.setBlockData(editor, node.blockId as string, { level });
deletePrefix(editor, level);
return;
}
CustomEditor.turnToBlock<HeadingBlockData>(editor, node.blockId as string, BlockType.HeadingBlock, { level });
deletePrefix(editor, level);
},
},
{
type: 'block',
match: /^>\s/,
format: BlockType.ToggleListBlock,
filter: (editor) => {
return getNodeType(editor) === BlockType.ToggleListBlock;
},
transform: (editor) => {
const type = getNodeType(editor);
let level: number | undefined;
// If the current block is a heading block, we need to get the level of the heading block
if (type === BlockType.HeadingBlock) {
level = (getBlockData(editor) as HeadingBlockData).level;
}
CustomEditor.turnToBlock<ToggleListBlockData>(editor, getBlockEntry(editor)[0].blockId as string, BlockType.ToggleListBlock, {
collapsed: false,
level,
});
deletePrefix(editor, 1);
},
},
{
type: 'block',
match: /^"\s/,
@ -130,25 +170,15 @@ const rules: Rule[] = [
deletePrefix(editor, match[0].length - 1);
},
},
{
type: 'block',
match: /^>\s/,
format: BlockType.ToggleListBlock,
filter: (editor) => {
return getNodeType(editor) === BlockType.ToggleListBlock;
},
transform: (editor) => {
CustomEditor.turnToBlock<ToggleListBlockData>(editor, getBlockEntry(editor)[0].blockId as string, BlockType.ToggleListBlock, { collapsed: false });
deletePrefix(editor, 1);
},
},
{
type: 'block',
match: /^(`){3,}$/,
format: BlockType.CodeBlock,
filter: (editor) => {
return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.CodeBlock;
const text = getLineText(editor);
return text !== '``' || getNodeType(editor) === BlockType.CodeBlock;
},
transform: (editor) => {
@ -178,7 +208,7 @@ const rules: Rule[] = [
const blockType = getNodeType(editor);
const blockData = getBlockData(editor);
return blockType === BlockType.HeadingBlock || (blockType === BlockType.NumberedListBlock && (blockData as NumberedListBlockData).number === start);
return ('level' in blockData && (blockData as HeadingBlockData).level > 0) || (blockType === BlockType.NumberedListBlock && (blockData as NumberedListBlockData).number === start);
},
transform: (editor, match) => {
const start = parseInt(match[1]);
@ -190,15 +220,17 @@ const rules: Rule[] = [
{
type: 'block',
match: /^([-*_]){3,}$/,
match: /^([-*_]){3,}|(—-+)$/,
format: BlockType.DividerBlock,
filter: (editor) => {
return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.DividerBlock;
const text = getLineText(editor);
return (['--', '**', '__', '—'].every(t => t !== text)) || getNodeType(editor) === BlockType.DividerBlock;
},
transform: (editor) => {
transform: (editor, match) => {
CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.DividerBlock, {});
deletePrefix(editor, 2);
deletePrefix(editor, match[0].length - 1);
},
},
@ -236,6 +268,34 @@ const rules: Rule[] = [
return text.length === 0;
},
},
{
type: 'mark',
match: /\[(.*?)\]\((.*?)\)/,
format: EditorMarkFormat.Href,
filter: (_editor, match) => {
const href = match[2];
return href.length === 0;
},
transform: (editor, match) => {
const href = match[2];
const text = match[1];
const { selection } = editor;
if (!selection) return;
const path = selection.anchor.path;
const start = match.index!;
editor.insertText(text);
Transforms.select(editor, {
anchor: { path, offset: start },
focus: { path, offset: start + text.length },
});
CustomEditor.addMark(editor, { key: EditorMarkFormat.Href, value: href });
},
},
{
type: 'mark',
match: /\$(.*?)\$/,
@ -257,6 +317,32 @@ const rules: Rule[] = [
CustomEditor.addMark(editor, { key: EditorMarkFormat.Formula, value: formula });
},
},
{
type: 'symbol',
match: /--/,
format: SpecialSymbol.EM_DASH,
transform: (editor) => {
editor.delete({
unit: 'character',
reverse: true,
});
editor.insertText('—');
},
},
{
type: 'symbol',
match: /=>/,
format: SpecialSymbol.RIGHTWARDS_DOUBLE_ARROW,
transform: (editor) => {
editor.delete({
unit: 'character',
reverse: true,
});
editor.insertText('⇒');
},
},
];
export const applyMarkdown = (editor: YjsEditor, insertText: string): boolean => {
@ -328,6 +414,26 @@ export const applyMarkdown = (editor: YjsEditor, insertText: string): boolean =>
return true;
}
} else if (rule.type === 'symbol') {
const path = selection.anchor.path;
const text = editor.string({
anchor: {
path,
offset: 0,
},
focus: selection.focus,
}) + insertText;
const match = text.match(rule.match);
if (match) {
if (rule.transform) {
rule.transform(editor, match);
}
return true;
}
console.log('symbol text', text, match);
}
}