From f6e002edbd095d60fc89ab74ccacdabd474e293e Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:06:38 +0800 Subject: [PATCH] feat: support toggle heading (#6712) * feat: support toggle heading * fix: support others markdown --- .../application/slate-yjs/command/index.ts | 2 +- .../application/slate-yjs/utils/applyToYjs.ts | 8 +- .../slate-yjs/utils/yjsOperations.ts | 68 +++++++- .../__tests__/shortcuts/Markdown.cy.tsx | 145 ++++++++++++++++- .../blocks/toggle-list/ToggleIcon.tsx | 2 +- .../blocks/toggle-list/ToggleList.tsx | 30 +++- .../src/components/editor/editor.scss | 38 +++++ .../editor/plugins/withInsertText.ts | 2 +- .../src/components/editor/utils/markdown.ts | 152 +++++++++++++++--- 9 files changed, 406 insertions(+), 41 deletions(-) diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts index db6dc0fa8d..a50b60471e 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts @@ -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; } diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToYjs.ts index 6a237bccce..e805f7d593 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToYjs.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToYjs.ts @@ -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); diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts index 9631e62e1f..3bd7c08763 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts @@ -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 (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; } -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'); diff --git a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx index 73c1c15e13..4f344e1100 100644 --- a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx +++ b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/Markdown.cy.tsx @@ -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: {}, diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx index f0fb5d4ba7..70c4db447e 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleIcon.tsx @@ -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 ? : } diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleList.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleList.tsx index 2f08ed8a15..ebe2e0792d 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleList.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/toggle-list/ToggleList.tsx @@ -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>(({ 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 ( <> -
{children}
diff --git a/frontend/appflowy_web_app/src/components/editor/editor.scss b/frontend/appflowy_web_app/src/components/editor/editor.scss index 69de74b9ab..5b90c8cf89 100644 --- a/frontend/appflowy_web_app/src/components/editor/editor.scss +++ b/frontend/appflowy_web_app/src/components/editor/editor.scss @@ -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; + } + } } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertText.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertText.ts index 0952ddb424..44efc2ad2b 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertText.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertText.ts @@ -22,7 +22,7 @@ export const withInsertText = (editor: ReactEditor) => { const [textNode] = textEntry as NodeEntry; // 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 }); diff --git a/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts b/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts index 53905b1f4c..8e4b0122c3 100644 --- a/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts +++ b/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts @@ -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(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(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(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); } }