diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index f4f38042a4ee0..9eb8699d4e954 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -46,6 +46,7 @@ "dexie-react-hooks": "^1.1.7", "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", + "escape-string-regexp": "^5.0.0", "events": "^3.3.0", "google-protobuf": "^3.15.12", "highlight.js": "^11.10.0", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 07ebdc65045ef..eecd89ab6884e 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -71,6 +71,9 @@ dependencies: emoji-regex: specifier: ^10.2.1 version: 10.3.0 + escape-string-regexp: + specifier: ^5.0.0 + version: 5.0.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -6389,6 +6392,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + /escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + dev: false + /escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/__tests__/withTestingYjsEditor.ts b/frontend/appflowy_web_app/src/application/slate-yjs/__tests__/withTestingYjsEditor.ts index 02b37633a8a24..6736e3d940fcb 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/__tests__/withTestingYjsEditor.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/__tests__/withTestingYjsEditor.ts @@ -20,6 +20,7 @@ export function generateId () { export function withTestingYjsEditor (editor: Editor, doc: Y.Doc) { const yjdEditor = withYjs(editor, doc, { localOrigin: CollabOrigin.Local, + readOnly: true, }); return yjdEditor; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts b/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts index bb2b2ab156f77..2c05c7b6cab9a 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts @@ -12,4 +12,5 @@ export const CONTAINER_BLOCK_TYPES = [ BlockType.BulletedListBlock, BlockType.NumberedListBlock, BlockType.Page, -]; \ No newline at end of file +]; +export const SOFT_BREAK_TYPES = [BlockType.CalloutBlock, BlockType.CodeBlock]; \ No newline at end of file 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 063dca660142d..96956ad3269e9 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 @@ -9,25 +9,30 @@ import { getSharedRoot, handleCollapsedBreakWithTxn, handleDeleteEntireDocumentWithTxn, + handleIndentBlockWithTxn, + handleLiftBlockOnBackspaceAndEnterWithTxn, + handleLiftBlockOnTabWithTxn, handleMergeBlockBackwardWithTxn, - handleNonParagraphBlockBackspaceWithTxn, - handleRangeBreak, - handleLiftBlockOnBackspaceWithTxn, handleMergeBlockForwardWithTxn, - removeRangeWithTxn, handleIndentBlockWithTxn, handleLiftBlockOnTabWithTxn, turnToBlock, + handleNonParagraphBlockBackspaceAndEnterWithTxn, + handleRangeBreak, + removeRangeWithTxn, + turnToBlock, } from '@/application/slate-yjs/utils/yjsOperations'; import { BlockData, BlockType, InlineBlockType, Mention, - MentionType, TodoListBlockData, - ToggleListBlockData, YBlock, - YjsEditorKey, YSharedRoot, + MentionType, + TodoListBlockData, + ToggleListBlockData, + YjsEditorKey, } from '@/application/types'; import { FormulaNode } from '@/components/editor/editor.type'; import { renderDate } from '@/utils/time'; -import { BasePoint, BaseRange, Editor, Element, Node, NodeEntry, Range, Text, Transforms } from 'slate'; +import isEqual from 'lodash-es/isEqual'; +import { BasePoint, BaseRange, Editor, Element, Node, NodeEntry, Path, Range, Text, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; export const CustomEditor = { @@ -79,9 +84,8 @@ export const CustomEditor = { }, setBlockData (editor: YjsEditor, blockId: string, updateData: T, select?: boolean) { - const readonly = editor.isElementReadOnly(editor); - if (readonly) { + if (editor.readOnly) { return; } @@ -91,25 +95,36 @@ export const CustomEditor = { ...oldData, ...updateData, }; - const operations = [() => { - block.set(YjsEditorKey.block_data, JSON.stringify(newData)); - }]; - const entry = Editor.above(editor, { + + const newProperties = { + data: newData, + } as Partial; + const [entry] = editor.nodes({ + at: [], match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, }); - executeOperations(editor.sharedRoot, operations, 'setBlockData'); - - if (!select) return; - if (!entry) { - Transforms.select(editor, Editor.start(editor, [0])); + console.error('Block not found'); return; } - const nodePath = entry[1]; + const [, path] = entry; + let atChild = false; + const { selection } = editor; + + if (selection && Path.isAncestor(path, selection.anchor.path)) { + atChild = true; + } + + Transforms.setNodes(editor, newProperties, { at: path }); + + if (!select) return; + + if (atChild) { + Transforms.select(editor, Editor.start(editor, path)); + } - Transforms.select(editor, Editor.start(editor, nodePath)); }, // Insert break line at the specified path insertBreak (editor: YjsEditor, at?: BaseRange) { @@ -142,11 +157,11 @@ export const CustomEditor = { const blockType = block.get(YjsEditorKey.block_type) as BlockType; if (blockType !== BlockType.Paragraph) { - handleNonParagraphBlockBackspaceWithTxn(sharedRoot, block); + handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block); return; } - if (path.length > 1 && handleLiftBlockOnBackspaceWithTxn(editor, sharedRoot, block, point)) { + if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { return; } @@ -256,6 +271,12 @@ export const CustomEditor = { const operations: (() => void)[] = []; const sharedRoot = getSharedRoot(editor); const sourceBlock = getBlock(blockId, sharedRoot); + const sourceType = sourceBlock.get(YjsEditorKey.block_type) as BlockType; + const oldData = dataStringTOJson(sourceBlock.get(YjsEditorKey.block_data)); + + if (sourceType === type && isEqual(oldData, data)) { + return; + } operations.push(() => { turnToBlock(sharedRoot, sourceBlock, type, data); diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withHistory.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withHistory.ts index 5c92deb0324c4..be8f7a6c182a5 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withHistory.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withHistory.ts @@ -39,7 +39,7 @@ export function withYHistory ( ): T & YHistoryEditor { const e = editor as T & YHistoryEditor; - if (Editor.isElementReadOnly(e, e)) { + if (e.readOnly) { return e; } @@ -52,6 +52,7 @@ export function withYHistory ( e.onChange = () => { onChange(); + const selection = e.selection; try { diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts index 2484fb90bd75b..69d9225d92783 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts @@ -11,6 +11,7 @@ type LocalChange = { }; export interface YjsEditor extends Editor { + readOnly: boolean; isYjsEditor: (value: unknown) => value is YjsEditor; connect: () => void; disconnect: () => void; @@ -71,16 +72,18 @@ export function withYjs ( editor: T, doc: Y.Doc, opts?: { + readOnly: boolean; localOrigin: CollabOrigin; readSummary?: boolean; onContentChange?: (content: Descendant[]) => void; }, ): T & YjsEditor { - const { localOrigin = CollabOrigin.Local, readSummary, onContentChange } = opts ?? {}; + const { localOrigin = CollabOrigin.Local, readSummary, onContentChange, readOnly = true } = opts ?? {}; const e = editor as T & YjsEditor; const { apply, onChange } = e; e.interceptLocalChange = false; + e.readOnly = readOnly; e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/types.ts b/frontend/appflowy_web_app/src/application/slate-yjs/types.ts index 107ca0129a254..9d69589193bbf 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/types.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/types.ts @@ -27,6 +27,8 @@ export enum EditorMarkFormat { StrikeThrough = 'strikethrough', Code = 'code', Href = 'href', + Formula = 'formula', + Mention = 'mention', FontColor = 'font_color', BgColor = 'bg_color', Align = 'align', 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 e923825de885e..7d71c935a4c50 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 @@ -1,16 +1,16 @@ import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { getText } from '@/application/slate-yjs/utils/yjsOperations'; import { calculateOffsetRelativeToParent } from '@/application/slate-yjs/utils/positions'; +import { getBlock, getNodeAtPath, getText } from '@/application/slate-yjs/utils/yjsOperations'; import { YjsEditorKey, YSharedRoot } from '@/application/types'; import { - Operation, - Element, + Descendant, Editor, + Element, InsertTextOperation, + Operation, RemoveTextOperation, - Descendant, - SetNodeOperation, Path, + SetNodeOperation, } from 'slate'; import * as Y from 'yjs'; @@ -37,6 +37,8 @@ function applyInsertText (ydoc: Y.Doc, editor: Editor, op: InsertTextOperation, const textId = node.textId as string; const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; const yText = getText(textId, sharedRoot); + + if (!yText) return; const point = { path, offset }; const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length); @@ -44,32 +46,6 @@ function applyInsertText (ydoc: Y.Doc, editor: Editor, op: InsertTextOperation, yText.insert(relativeOffset, text); } -function getNodeAtPath (children: Descendant[], path: Path): Descendant | null { - let currentNode: Descendant | null = null; - let currentChildren = children; - - for (let i = 0; i < path.length; i++) { - const index = path[i]; - - if (index >= currentChildren.length) { - return null; - } - - currentNode = currentChildren[index]; - if (i === path.length - 1) { - return currentNode; - } - - if (!Element.isElement(currentNode) || !currentNode.children) { - return null; - } - - currentChildren = currentNode.children; - } - - return currentNode; -} - function applyRemoveText (ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation, slateContent: Descendant[]) { const { path, offset, text } = op; @@ -81,6 +57,9 @@ function applyRemoveText (ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation, const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; const yText = getText(textId, sharedRoot); + + if (!yText) return; + const point = { path, offset }; const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length); @@ -100,26 +79,45 @@ function applySetNode (ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateC const properties = Object.keys(newProperties); const isLeaf = properties.some((prop: string) => leafKeys.includes(prop)); + const isData = properties.some((prop: string) => prop === 'data'); + const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; - if (!isLeaf) { - console.log('set_node', newProperties); - return; - } + if (isLeaf) { + const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element; + const textId = node.textId; - const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element; - const textId = node.textId; + if (!textId) return; - if (!textId) return; + const yText = getText(textId, sharedRoot); + const [start, end] = Editor.edges(editor, path); - const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; - const yText = getText(textId, sharedRoot); - const [start, end] = Editor.edges(editor, path); + const startRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, start), yText.toJSON().length); + const endRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, end), yText.toJSON().length); - const startRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, start), yText.toJSON().length); - const endRelativeOffset = Math.min(calculateOffsetRelativeToParent(node, end), yText.toJSON().length); + const length = endRelativeOffset - startRelativeOffset; - const length = endRelativeOffset - startRelativeOffset; + yText.format(startRelativeOffset, length, newProperties); + return; + } + + if (isData) { + const node = getNodeAtPath(slateContent, path) as Element; + const blockId = node.blockId as string; + + if (!blockId) { + console.error('blockId is not found in node', node, newProperties); + return; + } - yText.format(startRelativeOffset, length, newProperties); + const block = getBlock(blockId, sharedRoot); + + if ( + 'data' in newProperties + ) { + block.set(YjsEditorKey.block_data, JSON.stringify(newProperties.data)); + return; + } + } + console.error('set_node operation is not supported', op); } \ No newline at end of file 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 6cec23b5454e3..d424f1b010bef 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 @@ -15,7 +15,7 @@ import { import { nanoid } from 'nanoid'; import Delta, { Op } from 'quill-delta'; -import { BaseRange, Editor, Element, Node, NodeEntry, Path, Point, Range, Transforms } from 'slate'; +import { BaseRange, Descendant, Editor, Element, Node, NodeEntry, Path, Point, Range, Transforms } from 'slate'; import * as Y from 'yjs'; import { YjsEditor } from '../plugins/withYjs'; import { slatePointToRelativePosition } from './positions'; @@ -109,7 +109,7 @@ export function updateBlockParent (sharedRoot: YSharedRoot, block: YBlock, paren export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { const { startBlock, startOffset } = getBreakInfo(editor, sharedRoot, at); - const [blockNode] = startBlock; + const [blockNode, path] = startBlock; const blockId = blockNode.blockId as string; const block = getBlock(blockId, sharedRoot); @@ -117,6 +117,22 @@ export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSha throw new Error('Block not found'); } + const blockType = block.get(YjsEditorKey.block_type); + const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot); + + if (yText.length === 0) { + if (blockType !== BlockType.Paragraph) { + handleNonParagraphBlockBackspaceAndEnterWithTxn(sharedRoot, block); + return; + } + + const point = Editor.start(editor, at); + + if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { + return; + } + } + const { operations, select } = getSplitBlockOperations(sharedRoot, block, startOffset); executeOperations(sharedRoot, operations, 'insertBreak'); @@ -199,12 +215,6 @@ export function turnToBlock (sharedRoot: YSharedRoot, sourc copyBlockText(sharedRoot, sourceBlock, newBlock); - if (CONTAINER_BLOCK_TYPES.includes(type)) { - transferChildren(sharedRoot, sourceBlock, newBlock); - } else { - liftChildren(sharedRoot, sourceBlock, newBlock); - } - const parent = getBlock(sourceBlock.get(YjsEditorKey.block_parent), sharedRoot); if (!parent) return; @@ -212,10 +222,16 @@ export function turnToBlock (sharedRoot: YSharedRoot, sourc const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); const index = parentChildren.toArray().findIndex((id) => id === sourceBlock.get(YjsEditorKey.block_id)); + updateBlockParent(sharedRoot, newBlock, parent, index); + + if (CONTAINER_BLOCK_TYPES.includes(type)) { + transferChildren(sharedRoot, sourceBlock, newBlock); + } else { + liftChildren(sharedRoot, sourceBlock, newBlock); + } + // delete source block deleteBlock(sharedRoot, sourceBlock.get(YjsEditorKey.block_id)); - - updateBlockParent(sharedRoot, newBlock, parent, index); } function getSplitBlockOperations (sharedRoot: YSharedRoot, block: YBlock, offset: number): { @@ -225,30 +241,21 @@ function getSplitBlockOperations (sharedRoot: YSharedRoot, block: YBlock, offset const operations: (() => void)[] = []; if (offset === 0) { - const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot); - - if (yText.length === 0) { - operations.push(() => { - turnToBlock(sharedRoot, block, BlockType.Paragraph, {}); - }); - return { operations, select: false }; - } else { - operations.push(() => { - const type = block.get(YjsEditorKey.block_type); - const data = dataStringTOJson(block.get(YjsEditorKey.block_data)); - const isList = ListBlockTypes.includes(type); - const newBlock = createBlock(sharedRoot, { - ty: isList ? type : BlockType.Paragraph, - data: isList ? data : {}, - }); - const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); - const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); - const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); - const prevIndex = index <= 0 ? 0 : index; - - updateBlockParent(sharedRoot, newBlock, parent, prevIndex); + operations.push(() => { + const type = block.get(YjsEditorKey.block_type); + const data = dataStringTOJson(block.get(YjsEditorKey.block_data)); + const isList = ListBlockTypes.includes(type); + const newBlock = createBlock(sharedRoot, { + ty: isList ? type : BlockType.Paragraph, + data: isList ? data : {}, }); - } + const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); + const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); + const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); + const prevIndex = index <= 0 ? 0 : index; + + updateBlockParent(sharedRoot, newBlock, parent, prevIndex); + }); return { operations, select: true }; } @@ -537,11 +544,13 @@ export function getSplitBlockType (block: YBlock) { } } - case BlockType.HeadingBlock: - case BlockType.QuoteBlock: - return BlockType.Paragraph; - default: + case BlockType.BulletedListBlock: + case BlockType.NumberedListBlock: + case BlockType.TodoListBlock: return block.get(YjsEditorKey.block_type); + + default: + return BlockType.Paragraph; } } @@ -566,7 +575,7 @@ export function splitBlock (sharedRoot: YSharedRoot, block: YBlock, offset: numb const blockType = block.get(YjsEditorKey.block_type); - if (blockType === BlockType.ToggleListBlock) { + if (blockType === BlockType.ToggleListBlock || blockType === BlockType.QuoteBlock) { const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; if (!data.collapsed) { @@ -716,7 +725,14 @@ export function getSelectionOrThrow (editor: YjsEditor, at?: BaseRange) { return newAt; } -export function getBlockEntry (editor: YjsEditor, point: Point) { +export function getBlockEntry (editor: YjsEditor, point?: Point) { + const { selection } = editor; + const at = point || (selection ? Editor.start(editor, selection) : null); + + if (!at) { + throw new Error('Point not found'); + } + const blockEntry = editor.above({ at: point, match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, @@ -729,7 +745,7 @@ export function getBlockEntry (editor: YjsEditor, point: Point) { return blockEntry as NodeEntry; } -export function handleNonParagraphBlockBackspaceWithTxn (sharedRoot: YSharedRoot, block: YBlock) { +export function handleNonParagraphBlockBackspaceAndEnterWithTxn (sharedRoot: YSharedRoot, block: YBlock) { const operations: (() => void)[] = []; operations.push(() => { @@ -760,7 +776,7 @@ export function handleLiftBlockOnTabWithTxn (editor: YjsEditor, sharedRoot: YSha Transforms.select(editor, newPoint); } -export function handleLiftBlockOnBackspaceWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { +export function handleLiftBlockOnBackspaceAndEnterWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, block: YBlock, point: Point) { const operations: (() => void)[] = []; const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); @@ -1142,4 +1158,30 @@ export function handleDeleteEntireDocumentWithTxn (editor: YjsEditor) { executeOperations(sharedRoot, operations, 'deleteEntireDocument'); Transforms.select(editor, Editor.start(editor, [0])); +} + +export function getNodeAtPath (children: Descendant[], path: Path): Descendant | null { + let currentNode: Descendant | null = null; + let currentChildren = children; + + for (let i = 0; i < path.length; i++) { + const index = path[i]; + + if (index >= currentChildren.length) { + return null; + } + + currentNode = currentChildren[index]; + if (i === path.length - 1) { + return currentNode; + } + + if (!Element.isElement(currentNode) || !currentNode.children) { + return null; + } + + currentChildren = currentNode.children; + } + + return currentNode; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx index 92f2e153733f5..43387ef60b0ad 100644 --- a/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx +++ b/frontend/appflowy_web_app/src/components/editor/CollaborativeEditor.tsx @@ -14,6 +14,7 @@ const defaultInitialValue: Descendant[] = []; function CollaborativeEditor ({ doc }: { doc: Y.Doc }) { const context = useEditorContext(); const readSummary = context.readSummary; + const readOnly = context.readOnly; const localOrigin = CollabOrigin.Local; const [, setClock] = useState(0); const onContentChange = useCallback(() => { @@ -26,6 +27,7 @@ function CollaborativeEditor ({ doc }: { doc: Y.Doc }) { withReact( withYHistory( withYjs(createEditor(), doc, { + readOnly: readOnly, localOrigin, readSummary, onContentChange, @@ -33,7 +35,7 @@ function CollaborativeEditor ({ doc }: { doc: Y.Doc }) { ), ), ) as YjsEditor), - [onContentChange, readSummary, doc, localOrigin], + [doc, readOnly, localOrigin, readSummary, onContentChange], ); const [, setIsConnected] = useState(false); diff --git a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/BIUS.cy.tsx b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/BIUS.cy.tsx index 513aa8deb95d1..4753192bf94ff 100644 --- a/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/BIUS.cy.tsx +++ b/frontend/appflowy_web_app/src/components/editor/__tests__/shortcuts/BIUS.cy.tsx @@ -1,4 +1,4 @@ -import { mountEditor, moveCursor } from '@/components/editor/__tests__/mount'; +import { mountEditor } from '@/components/editor/__tests__/mount'; import { DocumentTest, FromBlockJSON } from 'cypress/support/document'; let documentTest: DocumentTest; 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 e69de29bb2d1d..e28491819f679 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 @@ -0,0 +1,243 @@ +import { mountEditor, moveCursor } from '@/components/editor/__tests__/mount'; +import { DocumentTest, FromBlockJSON } from 'cypress/support/document'; + +let documentTest: DocumentTest; +const initialData: FromBlockJSON[] = [{ + type: 'paragraph', + data: {}, + text: [{ insert: 'First paragraph' }], + children: [], +}]; + +const initializeEditor = (data: FromBlockJSON[]) => { + documentTest = new DocumentTest(); + documentTest.fromJSON(data); + mountEditor({ readOnly: false, doc: documentTest.doc }); + cy.get('[role="textbox"]').should('exist'); +}; + +const assertJSON = (expectedJSON: FromBlockJSON[]) => { + cy.wrap(null).then(() => { + const finalJSON = documentTest.toJSON(); + + expect(finalJSON).to.deep.equal(expectedJSON); + }); +}; + +describe('Markdown editing', () => { + beforeEach(() => { + cy.viewport(1280, 720); + Object.defineProperty(window.navigator, 'language', { value: 'en-US' }); + initializeEditor(initialData); + const selector = '[role="textbox"]'; + + cy.get(selector).as('editor'); + }); + + it('should handle all markdown inputs', () => { + // Test 1: heading + moveCursor(0, 6); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('##'); + cy.get('@editor').realPress('Space'); + let expectedJson: FromBlockJSON[] = [{ + type: 'paragraph', + data: {}, + text: [{ insert: 'First ' }], + children: [], + }, { + type: 'heading', + data: { level: 2 }, + text: [{ insert: 'paragraph' }], + children: [], + }]; + + assertJSON(expectedJson); + moveCursor(1, 9); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('"'); + cy.get('@editor').realPress('Space'); + cy.get('@editor').type('quote list'); + + // Test 2: quote + moveCursor(2, 10); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('quote child'); + + expectedJson = [ + expectedJson[0], + expectedJson[1], + { + type: 'quote', + data: {}, + text: [{ insert: 'quote list' }], + children: [{ + type: 'paragraph', + data: {}, + children: [], + text: [{ insert: 'quote child' }], + }], + }, + ]; + assertJSON(expectedJson); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').realPress(['Shift', 'Tab']); + + // Test 3: Todo list + cy.get('@editor').type('-[]'); + cy.get('@editor').realPress('Space'); + + expectedJson = [ + ...expectedJson, + { + type: 'todo_list', + data: { checked: false }, + text: [], + children: [], + }, + ]; + assertJSON(expectedJson); + cy.get('@editor').realPress('Backspace'); + cy.get('@editor').type('-[x]'); + cy.get('@editor').realPress('Space'); + expectedJson = [ + ...expectedJson.slice(0, -1), + { + type: 'todo_list', + data: { checked: true }, + text: [], + children: [], + }, + ]; + assertJSON(expectedJson); + cy.get('@editor').realPress('Backspace'); + cy.get('@editor').type('-[ ]'); + cy.get('@editor').realPress('Space'); + expectedJson = [ + ...expectedJson.slice(0, -1), + { + type: 'todo_list', + data: { checked: false }, + text: [], + children: [], + }, + ]; + assertJSON(expectedJson); + cy.get('@editor').type('todo list unchecked'); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('-[x]'); + cy.get('@editor').realPress('Space'); + cy.get('@editor').type('todo list checked'); + expectedJson = [ + ...expectedJson.slice(0, -1), + { + type: 'todo_list', + data: { checked: false }, + text: [{ insert: 'todo list unchecked' }], + children: [], + }, + { + type: 'todo_list', + data: { checked: true }, + text: [{ insert: 'todo list checked' }], + children: [], + }, + ]; + assertJSON(expectedJson); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').realPress('Backspace'); + + // Test 4: Toggle list + cy.get('@editor').type('>'); + cy.get('@editor').realPress('Space'); + cy.get('@editor').type('toggle list'); + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('toggle list child'); + expectedJson = [ + ...expectedJson, + { + type: 'toggle_list', + data: { collapsed: false }, + text: [{ insert: 'toggle list' }], + children: [{ + type: 'paragraph', + data: {}, + children: [], + text: [{ insert: 'toggle list child' }], + }], + }, + ]; + assertJSON(expectedJson); + + // Test 5: Bullted List + cy.get('@editor').realPress('Enter'); + cy.get('@editor').realPress('Backspace'); + cy.get('@editor').type('-'); + cy.get('@editor').realPress('Space'); + cy.get('@editor').type('bulleted list'); + expectedJson = [ + ...expectedJson, + { + type: 'bulleted_list', + data: {}, + text: [{ insert: 'bulleted list' }], + children: [], + }, + + ]; + assertJSON(expectedJson); + // Test 5: Numbered List + cy.get('@editor').realPress('Enter'); + cy.get('@editor').type('2.'); + cy.get('@editor').realPress('Space'); + cy.get('@editor').type('numbered list'); + expectedJson = [ + ...expectedJson, + { + type: 'numbered_list', + data: { number: 2 }, + text: [{ insert: 'numbered list' }], + children: [], + }, + ]; + assertJSON(expectedJson); + + // Test 6: Code block + cy.get('@editor').realPress('Enter'); + cy.get('@editor').realPress('Backspace'); + cy.get('@editor').type('```'); + cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').realPress(['Shift', 'Enter']); + expectedJson = [ + ...expectedJson, + { + type: 'code', + data: {}, + text: [{ + insert: 'function main() {\n console.log(\'Hello, World!\');\n}', + }], + children: [], + }, + { + type: 'paragraph', + data: {}, + text: [], + children: [], + }, + ]; + assertJSON(expectedJson); + // Last test: Divider + cy.get('@editor').type('--'); + cy.get('@editor').realPress('-'); + expectedJson = [ + ...expectedJson.slice(0, -1), + { + type: 'divider', + data: {}, + text: [], + children: [], + }, + ]; + assertJSON(expectedJson); + }); +}); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx index 6e69990fbf1e8..75e799f7401ab 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/Quote.tsx @@ -4,15 +4,18 @@ import React, { forwardRef, memo, useMemo } from 'react'; export const Quote = memo( forwardRef>(({ node: _, children, ...attributes }, ref) => { const className = useMemo(() => { - return `my-1 ${attributes.className ?? ''}`; + return `my-1 ${attributes.className ?? ''} pl-3`; }, [attributes.className]); return (
- {children} +
+ {children} +
+
); - }) + }), ); export default Quote; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/QuoteIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/QuoteIcon.tsx deleted file mode 100644 index fec907ced68ed..0000000000000 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/quote/QuoteIcon.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -function QuoteIcon({ className }: { className: string }) { - return ( - -
-
- ); -} - -export default QuoteIcon; diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx index 03cbd68397115..136e9583d20c2 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/text/StartIcon.hooks.tsx @@ -1,7 +1,6 @@ import { BlockType } from '@/application/types'; import { BulletedListIcon } from '@/components/editor/components/blocks/bulleted-list'; import { NumberListIcon } from '@/components/editor/components/blocks/numbered-list'; -import QuoteIcon from '@/components/editor/components/blocks/quote/QuoteIcon'; import ToggleIcon from '@/components/editor/components/blocks/toggle-list/ToggleIcon'; import { TextNode } from '@/components/editor/editor.type'; import React, { FC, useCallback, useMemo } from 'react'; @@ -9,7 +8,7 @@ import { ReactEditor, useSlate } from 'slate-react'; import { Editor, Element } from 'slate'; import CheckboxIcon from '@/components/editor/components/blocks/todo-list/CheckboxIcon'; -export function useStartIcon(node: TextNode) { +export function useStartIcon (node: TextNode) { const editor = useSlate(); const path = ReactEditor.findPath(editor, node); const block = Editor.parent(editor, path)?.[0] as Element | null; @@ -28,8 +27,6 @@ export function useStartIcon(node: TextNode) { return NumberListIcon; case BlockType.BulletedListBlock: return BulletedListIcon; - case BlockType.QuoteBlock: - return QuoteIcon; default: return null; } @@ -42,11 +39,7 @@ export function useStartIcon(node: TextNode) { const classList = ['text-block-icon relative w-[24px]']; - if (block.type === BlockType.QuoteBlock) { - classList.push('h-full min-w-[24px]'); - } else { - classList.push('h-6'); - } + classList.push('h-6'); return ; }, [Component, block]); diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo-list/CheckboxIcon.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo-list/CheckboxIcon.tsx index 034a3987bfdce..4a76234f48d29 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/blocks/todo-list/CheckboxIcon.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/todo-list/CheckboxIcon.tsx @@ -1,20 +1,43 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; import { TodoListNode } from '@/components/editor/editor.type'; -import React from 'react'; +import { debounce } from 'lodash-es'; +import React, { useCallback, useMemo } from 'react'; import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg'; +import { useReadOnly, useSlateStatic } from 'slate-react'; -function CheckboxIcon({ block, className }: { block: TodoListNode; className: string }) { +function CheckboxIcon ({ block, className }: { block: TodoListNode; className: string }) { const { checked } = block.data; + const editor = useSlateStatic(); + const readOnly = useReadOnly(); + + const toggleChecked = useMemo(() => { + if (readOnly) { + return; + } + + return debounce(() => { + CustomEditor.toggleTodoList(editor as YjsEditor, block.blockId); + }, 100); + }, [readOnly, editor, block.blockId]); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleChecked?.(); + }, [toggleChecked]); return ( { e.preventDefault(); }} - className={`${className} pr-1 text-xl text-fill-default`} + className={`${className} ${readOnly ? '' : 'cursor-pointer hover:text-fill-default'} pr-1 text-xl`} > {checked ? : } diff --git a/frontend/appflowy_web_app/src/components/editor/editor.scss b/frontend/appflowy_web_app/src/components/editor/editor.scss index 4455c89be35a1..2d48831fb4f68 100644 --- a/frontend/appflowy_web_app/src/components/editor/editor.scss +++ b/frontend/appflowy_web_app/src/components/editor/editor.scss @@ -10,6 +10,12 @@ margin-left: 24px; } +.block-element[data-block-type="quote"] { + .block-element { + margin-left: 0 !important; + } +} + .block-element[data-block-type="table/cell"] { margin-left: 0 !important; diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/index.ts b/frontend/appflowy_web_app/src/components/editor/plugins/index.ts index 7c32230c50f35..2dc3d88acb408 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/index.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/index.ts @@ -1,7 +1,8 @@ import { withDelete } from '@/components/editor/plugins/withDelete'; import { withInsertBreak } from '@/components/editor/plugins/withInsertBreak'; +import { withMarkdown } from '@/components/editor/plugins/withMarkdown'; import { ReactEditor } from 'slate-react'; export function withPlugins (editor: ReactEditor) { - return withInsertBreak(withDelete(editor)); + return withMarkdown(withInsertBreak(withDelete(editor))); } diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertBreak.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertBreak.ts index 7f280a0225a2b..7731e5d41ac1c 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withInsertBreak.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/withInsertBreak.ts @@ -6,7 +6,7 @@ export function withInsertBreak (editor: ReactEditor) { const { insertBreak } = editor; editor.insertBreak = () => { - if (editor.isElementReadOnly(editor)) { + if ((editor as YjsEditor).readOnly) { insertBreak(); return; } diff --git a/frontend/appflowy_web_app/src/components/editor/plugins/withMarkdown.ts b/frontend/appflowy_web_app/src/components/editor/plugins/withMarkdown.ts index e69de29bb2d1d..48f4722f1c584 100644 --- a/frontend/appflowy_web_app/src/components/editor/plugins/withMarkdown.ts +++ b/frontend/appflowy_web_app/src/components/editor/plugins/withMarkdown.ts @@ -0,0 +1,18 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { allTriggerChars, applyMarkdown } from '@/components/editor/utils/markdown'; +import { ReactEditor } from 'slate-react'; + +export const withMarkdown = (editor: ReactEditor) => { + const { insertText } = editor; + + editor.insertText = (text) => { + + if (allTriggerChars.has(text) && applyMarkdown(editor as ReactEditor & YjsEditor, text)) { + return; + } + + insertText(text); + }; + + return editor; +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/shortcut.hooks.ts b/frontend/appflowy_web_app/src/components/editor/shortcut.hooks.ts index 06f2a33abe3fe..13e89d60561b2 100644 --- a/frontend/appflowy_web_app/src/components/editor/shortcut.hooks.ts +++ b/frontend/appflowy_web_app/src/components/editor/shortcut.hooks.ts @@ -1,14 +1,13 @@ import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; +import { SOFT_BREAK_TYPES } from '@/application/slate-yjs/command/const'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; import { getBlockEntry } from '@/application/slate-yjs/utils/yjsOperations'; import { BlockType } from '@/application/types'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; -import { useCallback, KeyboardEvent } from 'react'; +import { KeyboardEvent, useCallback } from 'react'; import { ReactEditor, useReadOnly } from 'slate-react'; -const SOFT_BREAK_TYPES = [BlockType.CalloutBlock, BlockType.CodeBlock]; - export function useShortcuts (editor: ReactEditor) { const yjsEditor = editor as YjsEditor; const readOnly = useReadOnly(); @@ -16,12 +15,7 @@ export function useShortcuts (editor: ReactEditor) { const e = event.nativeEvent; const { selection } = editor; - if (!selection) return; - const [point, endPoint] = editor.edges(selection); - const node = getBlockEntry(yjsEditor, point); - const endNode = getBlockEntry(yjsEditor, endPoint); - const isSameBlock = node[0].blockId === endNode[0].blockId; - + // Add more cases here for general shortcuts switch (true) { /** * Escape: Esc @@ -30,6 +24,32 @@ export function useShortcuts (editor: ReactEditor) { case createHotkey(HOT_KEY_NAME.ESCAPE)(e): editor.deselect(); break; + + default: + break; + } + + // Do not process shortcuts if editor is read-only or no selection + if (readOnly || !selection) return; + const [point, endPoint] = editor.edges(selection); + const node = getBlockEntry(yjsEditor, point); + const endNode = getBlockEntry(yjsEditor, endPoint); + const isSameBlock = node[0].blockId === endNode[0].blockId; + + // Add more cases here for editing shortcuts + switch (!readOnly) { + /** + * Select all: Mod+A + * Default behavior: Select all text in the editor + * Special case for select all in code block: Only select all text in code block + */ + case createHotkey(HOT_KEY_NAME.SELECT_ALL)(e): + if (node && node[0].type === BlockType.CodeBlock) { + event.preventDefault(); + editor.select(node[1]); + } + + break; /** * Indent block: Tab * Default behavior: Indent block @@ -37,7 +57,7 @@ export function useShortcuts (editor: ReactEditor) { case createHotkey(HOT_KEY_NAME.INDENT_BLOCK)(e): event.preventDefault(); - if (readOnly || !isSameBlock) return; + if (!isSameBlock) return; if (SOFT_BREAK_TYPES.includes(node[0]?.type as BlockType)) { editor.insertText('\t'); break; @@ -51,7 +71,7 @@ export function useShortcuts (editor: ReactEditor) { */ case createHotkey(HOT_KEY_NAME.OUTDENT_BLOCK)(e): event.preventDefault(); - if (readOnly || !isSameBlock) return; + if (!isSameBlock) return; CustomEditor.tabBackward(yjsEditor, point); break; /** @@ -60,7 +80,6 @@ export function useShortcuts (editor: ReactEditor) { * Special case for soft break types: Insert \n */ case createHotkey(HOT_KEY_NAME.SPLIT_BLOCK)(e): - if (readOnly) break; if (SOFT_BREAK_TYPES.includes(node[0]?.type as BlockType)) { event.preventDefault(); editor.insertText('\n'); @@ -73,7 +92,6 @@ export function useShortcuts (editor: ReactEditor) { * Special case for soft break types: Split block */ case createHotkey(HOT_KEY_NAME.INSERT_SOFT_BREAK)(e): - if (readOnly) break; event.preventDefault(); if (node && SOFT_BREAK_TYPES.includes(node[0]?.type as BlockType)) { editor.insertBreak(); @@ -89,7 +107,6 @@ export function useShortcuts (editor: ReactEditor) { */ case createHotkey(HOT_KEY_NAME.TOGGLE_TODO)(e): case createHotkey(HOT_KEY_NAME.TOGGLE_COLLAPSE)(e): - if (readOnly) break; event.preventDefault(); if (node[0].type === BlockType.ToggleListBlock) { @@ -104,7 +121,6 @@ export function useShortcuts (editor: ReactEditor) { */ case createHotkey(HOT_KEY_NAME.BOLD)(e): event.preventDefault(); - if (readOnly) break; CustomEditor.toggleMark(editor, { key: EditorMarkFormat.Bold, value: true, 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 4f99c2700a0bb..323b043aea603 100644 --- a/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts +++ b/frontend/appflowy_web_app/src/components/editor/utils/markdown.ts @@ -1,143 +1,296 @@ -export type MarkdownRegex = { - [key in MarkdownShortcuts]: { - pattern: RegExp; +import { YjsEditor } from '@/application/slate-yjs'; +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 { + BlockType, + HeadingBlockData, + NumberedListBlockData, + TodoListBlockData, + ToggleListBlockData, + YjsEditorKey, +} from '@/application/types'; +import { Editor, Range, Transforms } from 'slate'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?: Record; - }[]; +type TriggerHotKey = { + [key in BlockType | EditorMarkFormat]?: string[]; }; -export type TriggerHotKey = { - [key in MarkdownShortcuts]: string[]; +const defaultTriggerChar: TriggerHotKey = { + [BlockType.HeadingBlock]: [' '], + [BlockType.QuoteBlock]: [' '], + [BlockType.CodeBlock]: ['`'], + [BlockType.BulletedListBlock]: [' '], + [BlockType.NumberedListBlock]: [' '], + [BlockType.TodoListBlock]: [' '], + [BlockType.ToggleListBlock]: [' '], + [BlockType.DividerBlock]: ['-', '*'], + [EditorMarkFormat.Bold]: ['*', '_'], + [EditorMarkFormat.Italic]: ['*', '_'], + [EditorMarkFormat.StrikeThrough]: ['~'], + [EditorMarkFormat.Code]: ['`'], + [EditorMarkFormat.Formula]: ['$'], }; -export enum MarkdownShortcuts { - Bold, - Italic, - StrikeThrough, - Code, - Equation, - /** block */ - Heading, - BlockQuote, - CodeBlock, - Divider, - /** list */ - BulletedList, - NumberedList, - TodoList, - ToggleList, +// create a set of all trigger characters +export const allTriggerChars = new Set(Object.values(defaultTriggerChar).flat()); + +// Define the rules for markdown shortcuts +type Rule = { + type: 'block' | 'mark' + match: RegExp + format: string + transform?: (editor: YjsEditor, match: RegExpMatchArray) => void + filter?: (editor: YjsEditor, match: RegExpMatchArray) => boolean } -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: /^(`{2,})$/, - data: { - language: 'json', - }, - }, - ], - [MarkdownShortcuts.Divider]: [ - { - pattern: /^(([-*]){2,})$/, - }, - ], - - [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, - }, - }, - ], -}; +function deletePrefix (editor: YjsEditor, offset: number) { + const [, path] = getBlockEntry(editor); -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]: [' '], -}; + const { selection } = editor; + + if (!selection) return; + editor.select({ + anchor: editor.start(path), + focus: { path: selection.focus.path, offset: offset }, + }); + editor.delete(); +} + +function getNodeType (editor: YjsEditor) { + const [node] = getBlockEntry(editor); + + return node.type as BlockType; +} + +function getBlockData (editor: YjsEditor) { + const [node] = getBlockEntry(editor); + + return node.data; +} + +function isEmptyLine (editor: YjsEditor, offset: number) { + 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; +} + +const rules: Rule[] = [ + // Blocks + { + type: 'block', + match: /^(#{1,6})\s/, + format: BlockType.HeadingBlock, + filter: (editor, match) => { + const level = match[1].length; + const blockType = getNodeType(editor); + const blockData = getBlockData(editor); + + return blockType === BlockType.HeadingBlock && (blockData as HeadingBlockData).level === level; + }, + transform: (editor, match) => { + const level = match[1].length; + const [node] = getBlockEntry(editor); + + deletePrefix(editor, level); + CustomEditor.turnToBlock(editor, node.blockId as string, BlockType.HeadingBlock, { level }); + }, + }, + { + type: 'block', + match: /^"\s/, + format: BlockType.QuoteBlock, + filter: (editor) => { + return getNodeType(editor) === BlockType.QuoteBlock; + }, + transform: (editor) => { + deletePrefix(editor, 1); + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.QuoteBlock, {}); + }, + }, + { + type: 'block', + match: /^(-)?\[(x| )?\]\s/, + format: BlockType.TodoListBlock, + filter: (editor, match) => { + const checked = match[2] === 'x'; + const blockType = getNodeType(editor); + const blockData = getBlockData(editor); + + return blockType === BlockType.TodoListBlock && (blockData as TodoListBlockData).checked === checked; + }, + transform: (editor, match) => { + deletePrefix(editor, match[0].length - 1); + const checked = match[2] === 'x'; + + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.TodoListBlock, { checked }); + }, + }, + { + type: 'block', + match: /^>\s/, + format: BlockType.ToggleListBlock, + filter: (editor) => { + return getNodeType(editor) === BlockType.ToggleListBlock; + }, + transform: (editor) => { + deletePrefix(editor, 1); + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.ToggleListBlock, { collapsed: false }); + }, + }, + { + type: 'block', + match: /^(`){3,}$/, + format: BlockType.CodeBlock, + filter: (editor) => { + return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.CodeBlock; + }, + transform: (editor) => { + deletePrefix(editor, 2); + + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.CodeBlock, {}); + }, + }, + { + type: 'block', + match: /^(-|\*|\+)\s/, + format: BlockType.BulletedListBlock, + filter: (editor) => { + return getNodeType(editor) === BlockType.BulletedListBlock; + }, + transform: (editor) => { + deletePrefix(editor, 1); + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.BulletedListBlock, {}); + }, + }, + { + type: 'block', + match: /^(\d+)\.\s/, + format: BlockType.NumberedListBlock, + filter: (editor, match) => { + const start = parseInt(match[1]); + const blockType = getNodeType(editor); + const blockData = getBlockData(editor); + + return blockType === BlockType.HeadingBlock || (blockType === BlockType.NumberedListBlock && (blockData as NumberedListBlockData).number === start); + }, + transform: (editor, match) => { + const start = parseInt(match[1]); + + deletePrefix(editor, String(start).length + 1); + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.NumberedListBlock, { number: start }); + }, + }, + + { + type: 'block', + match: /^([-*_]){3,}$/, + format: BlockType.DividerBlock, + filter: (editor) => { + return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.DividerBlock; + }, + transform: (editor) => { + deletePrefix(editor, 2); + CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.DividerBlock, {}); + }, + }, + + // marks + { + type: 'mark', + match: /\*\*(.*?)\*\*|__(.*?)__/, + format: EditorMarkFormat.Bold, + }, + { + type: 'mark', + match: /\*(.*?)\*|_(.*?)_/, + format: EditorMarkFormat.Italic, + }, + { + type: 'mark', + match: /~~(.*?)~~/, + format: EditorMarkFormat.StrikeThrough, + }, + // { + // type: 'mark', + // match: /`(.*?)`/, + // format: EditorMarkFormat.Code, + // }, + { + type: 'mark', + match: /\$(.*?)\$/, + format: EditorMarkFormat.Formula, + transform: (editor, match) => { + const formula = match[1]; + + CustomEditor.addMark(editor, { key: EditorMarkFormat.Formula, value: formula }); + }, + }, +]; + +export const applyMarkdown = (editor: YjsEditor, insertText: string): boolean => { + const { selection } = editor; + + if (!selection || !Range.isCollapsed(selection)) return false; + + const [, path] = getBlockEntry(editor); + const start = Editor.start(editor, path); + const text = editor.string({ + anchor: start, + focus: selection.focus, + }) + insertText; + + for (const rule of rules) { + if (rule.type === 'block') { + const match = text.match(rule.match); + + console.log('applyMarkdown match', match, rule.match, text); + if (match && !rule.filter?.(editor, match)) { + + if (rule.transform) { + rule.transform(editor, match); + } + + return true; + } + } else if (rule.type === 'mark') { + const path = selection.anchor.path; + const text = editor.string(path); + + const matches = [...text.matchAll(new RegExp(rule.match, 'g'))]; + + for (const match of matches.reverse()) { + const start = match.index!; + const end = start + match[0].length; + const matchRange = { + anchor: { path, offset: start }, + focus: { path, offset: end }, + }; + + Transforms.select(editor, matchRange); + editor.delete(); + + editor.insertText(match[1]); + Transforms.select(editor, { + anchor: { path, offset: start }, + focus: { path, offset: start + match[1].length }, + }); + + if (rule.transform) { + rule.transform(editor, match); + } else { + CustomEditor.addMark(editor, { key: rule.format as EditorMarkFormat, value: true }); + } + } + + if (matches.length > 0) { + return true; + } + } + } + return false; +}; \ No newline at end of file