From 2e2f1796566bde5b288eb7bc1ea5b1d4c6db49a0 Mon Sep 17 00:00:00 2001 From: Mirone Saul Date: Mon, 5 Sep 2022 19:07:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20make=20inline=20sync=20p?= =?UTF-8?q?lugin=20support=20global=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/plugin/inline-sync.ts | 245 ------------------ .../src/plugin/inline-sync/config.ts | 56 ++++ .../src/plugin/inline-sync/context.ts | 115 ++++++++ .../src/plugin/inline-sync/index.ts | 48 ++++ .../src/plugin/inline-sync/regexp.ts | 6 + .../src/plugin/inline-sync/replacer.ts | 43 +++ .../src/plugin/inline-sync/utils.ts | 73 ++++++ 7 files changed, 341 insertions(+), 245 deletions(-) delete mode 100644 packages/preset-commonmark/src/plugin/inline-sync.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/config.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/context.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/index.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/regexp.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/replacer.ts create mode 100644 packages/preset-commonmark/src/plugin/inline-sync/utils.ts diff --git a/packages/preset-commonmark/src/plugin/inline-sync.ts b/packages/preset-commonmark/src/plugin/inline-sync.ts deleted file mode 100644 index 2ad970f122d..00000000000 --- a/packages/preset-commonmark/src/plugin/inline-sync.ts +++ /dev/null @@ -1,245 +0,0 @@ -/* Copyright 2021, Milkdown by Mirone. */ -import { createSlice, Ctx, editorViewCtx, parserCtx, serializerCtx } from '@milkdown/core'; -import { Attrs, Node } from '@milkdown/prose/model'; -import { EditorState, Plugin, PluginKey, TextSelection, Transaction } from '@milkdown/prose/state'; -import { pipe } from '@milkdown/utils'; - -export type ShouldSyncNode = (context: { - prevNode: Node; - nextNode: Node; - ctx: Ctx; - tr: Transaction; - text: string; -}) => boolean; - -export type SyncNodePlaceholder = { - hole: string; - punctuation: string; - char: string; -}; - -export type inlineSyncConfig = { - placeholderConfig: SyncNodePlaceholder; - shouldSyncNode: ShouldSyncNode; - movePlaceholder: (placeholderToMove: string, text: string) => string; -}; - -export const inlineSyncConfigCtx = createSlice( - { - placeholderConfig: { - hole: '∅', - punctuation: '⁂', - char: '∴', - }, - shouldSyncNode: ({ prevNode, nextNode }) => - prevNode.inlineContent && - nextNode && - // if node type changes, do not sync - prevNode.type === nextNode.type && - // if two node fully equal, we don't modify them - !prevNode.eq(nextNode), - movePlaceholder: (placeholderToMove: string, text: string) => { - const symbolsNeedToMove = ['*', '_']; - - let index = text.indexOf(placeholderToMove); - while ( - symbolsNeedToMove.includes(text[index - 1] || '') && - symbolsNeedToMove.includes(text[index + 1] || '') - ) { - text = swap(text, index, index + 1); - index = index + 1; - } - - return text; - }, - }, - 'inlineSyncConfig', -); - -const linkRegexp = /\[(?((www|https:\/\/|http:\/\/)\S+))]\((?\S+)\)/; - -const punctuationRegexp = (holePlaceholder: string) => new RegExp(`\\\\(?=[^\\w\\s${holePlaceholder}\\\\]|_)`, 'g'); - -const keepLink = (str: string) => { - let text = str; - let match = text.match(linkRegexp); - while (match && match.groups) { - const { span } = match.groups; - text = text.replace(linkRegexp, span as string); - - match = text.match(linkRegexp); - } - return text; -}; - -const swap = (text: string, first: number, last: number) => { - const arr = text.split(''); - const temp = arr[first]; - if (arr[first] && arr[last]) { - arr[first] = arr[last] as string; - arr[last] = temp as string; - } - return arr.join('').toString(); -}; - -const removeLf = (text: string) => text.slice(0, -1); -const replacePunctuation = (holePlaceholder: string) => (text: string) => - text.replace(punctuationRegexp(holePlaceholder), ''); - -const calculatePlaceholder = (placeholder: SyncNodePlaceholder) => (text: string) => { - const index = text.indexOf(placeholder.hole); - const left = text.charAt(index - 1); - const right = text.charAt(index + 1); - const notAWord = /[^\w]|_/; - - // cursor on the right - if (!right) { - return placeholder.punctuation; - } - - // cursor on the left - if (!left) { - return placeholder.char; - } - - if (notAWord.test(left) && notAWord.test(right)) { - return placeholder.punctuation; - } - - return placeholder.char; -}; - -const getOffset = (node: Node, from: number, placeholder: string) => { - let offset = from; - let find = false; - node.descendants((n) => { - if (find) return false; - if (n.isText) { - const i = n.text?.indexOf(placeholder); - if (i != null && i >= 0) { - find = true; - offset += i; - return false; - } - } - offset += n.nodeSize; - return; - }); - return offset; -}; - -const getContextByState = (ctx: Ctx, state: EditorState) => { - const { selection } = state; - const { $from } = selection; - - const node = $from.node(); - const doc = state.schema.topNodeType.create(undefined, node); - - const parser = ctx.get(parserCtx); - const serializer = ctx.get(serializerCtx); - - const markdown = serializer(doc); - const config = ctx.get(inlineSyncConfigCtx); - const holePlaceholder = config.placeholderConfig.hole; - - const movePlaceholder = (text: string) => config.movePlaceholder(holePlaceholder, text); - - const handleText = pipe(removeLf, replacePunctuation(holePlaceholder), movePlaceholder, keepLink); - - let text = handleText(markdown); - const placeholder = calculatePlaceholder(config.placeholderConfig)(text); - - text = text.replace(holePlaceholder, placeholder); - - const parsed = parser(text); - if (!parsed) return null; - - const target = parsed.firstChild; - - if (!target || node.type !== target.type) return null; - - // @ts-expect-error hijack the node attribute - target.attrs = { ...node.attrs }; - - target.descendants((node) => { - const marks = node.marks; - const link = marks.find((mark) => mark.type.name === 'link'); - if (link && node.text?.includes(placeholder) && link.attrs['href'].includes(placeholder)) { - // @ts-expect-error hijack the mark attribute - link.attrs['href'] = link.attrs['href'].replace(placeholder, ''); - } - }); - - return { - text, - prevNode: node, - nextNode: target, - placeholder, - }; -}; - -export const inlineSyncPluginKey = new PluginKey('MILKDOWN_INLINE_SYNC'); -export const getInlineSyncPlugin = (ctx: Ctx) => { - const runReplacer = (state: EditorState, dispatch: (tr: Transaction) => void, attrs: Attrs) => { - const { placeholderConfig } = ctx.get(inlineSyncConfigCtx); - const holePlaceholder = placeholderConfig.hole; - // insert a placeholder to restore the selection - let tr = state.tr.setMeta(inlineSyncPluginKey, true).insertText(holePlaceholder, state.selection.from); - - const nextState = state.apply(tr); - const context = getContextByState(ctx, nextState); - - if (!context) return; - - const { $from } = nextState.selection; - const from = $from.before(); - const to = $from.after(); - - const offset = getOffset(context.nextNode, from, context.placeholder); - - tr = tr - .replaceWith(from, to, context.nextNode) - .setNodeMarkup(from, undefined, attrs) - // delete the placeholder - .delete(offset + 1, offset + 2); - - tr = tr.setSelection(TextSelection.near(tr.doc.resolve(offset + 1))); - dispatch(tr); - }; - - const inlineSyncPlugin = new Plugin({ - key: inlineSyncPluginKey, - state: { - init: () => { - return null; - }, - apply: (tr, _value, _oldState, newState) => { - if (!tr.docChanged) return null; - - const meta = tr.getMeta(inlineSyncPluginKey); - if (meta) { - return null; - } - - const context = getContextByState(ctx, newState); - if (!context) return null; - - const { prevNode, nextNode, text } = context; - - const { shouldSyncNode } = ctx.get(inlineSyncConfigCtx); - - if (!shouldSyncNode({ prevNode, nextNode, ctx, tr, text })) return null; - - requestAnimationFrame(() => { - const { dispatch, state } = ctx.get(editorViewCtx); - - runReplacer(state, dispatch, prevNode.attrs); - }); - - return null; - }, - }, - }); - - return inlineSyncPlugin; -}; diff --git a/packages/preset-commonmark/src/plugin/inline-sync/config.ts b/packages/preset-commonmark/src/plugin/inline-sync/config.ts new file mode 100644 index 00000000000..3405553f53b --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/config.ts @@ -0,0 +1,56 @@ +/* Copyright 2021, Milkdown by Mirone. */ +import { createSlice, Ctx } from '@milkdown/core'; +import { Node, NodeType } from '@milkdown/prose/model'; +import { Transaction } from '@milkdown/prose/state'; + +import { swap } from './utils'; + +export type ShouldSyncNode = (context: { + prevNode: Node; + nextNode: Node; + ctx: Ctx; + tr: Transaction; + text: string; +}) => boolean; + +export type SyncNodePlaceholder = { + hole: string; + punctuation: string; + char: string; +}; + +export type InlineSyncConfig = { + placeholderConfig: SyncNodePlaceholder; + shouldSyncNode: ShouldSyncNode; + globalNodes: Array; + movePlaceholder: (placeholderToMove: string, text: string) => string; +}; + +export const defaultConfig: InlineSyncConfig = { + placeholderConfig: { + hole: '∅', + punctuation: '⁂', + char: '∴', + }, + globalNodes: ['footnote_definition'], + shouldSyncNode: ({ prevNode, nextNode }) => + prevNode.inlineContent && + nextNode && + // if node type changes, do not sync + prevNode.type === nextNode.type && + // if two node fully equal, we don't modify them + !prevNode.eq(nextNode), + movePlaceholder: (placeholderToMove: string, text: string) => { + const symbolsNeedToMove = ['*', '_']; + + let index = text.indexOf(placeholderToMove); + while (symbolsNeedToMove.includes(text[index - 1] || '') && symbolsNeedToMove.includes(text[index + 1] || '')) { + text = swap(text, index, index + 1); + index = index + 1; + } + + return text; + }, +}; + +export const inlineSyncConfigCtx = createSlice(defaultConfig, 'inlineSyncConfig'); diff --git a/packages/preset-commonmark/src/plugin/inline-sync/context.ts b/packages/preset-commonmark/src/plugin/inline-sync/context.ts new file mode 100644 index 00000000000..9a4612cfbae --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/context.ts @@ -0,0 +1,115 @@ +/* Copyright 2021, Milkdown by Mirone. */ +import { Ctx, parserCtx, serializerCtx } from '@milkdown/core'; +import { Node } from '@milkdown/prose/model'; +import { EditorState } from '@milkdown/prose/state'; +import { pipe } from '@milkdown/utils'; + +import { inlineSyncConfigCtx } from './config'; +import { calculatePlaceholder, keepLink, replacePunctuation } from './utils'; + +export * from './config'; + +export type InlineSyncContext = { + text: string; + prevNode: Node; + nextNode: Node; + placeholder: string; +}; + +const getNodeFromSelection = (state: EditorState) => { + const { selection } = state; + const { $from } = selection; + const node = $from.node(); + + return node; +}; + +const getMarkdown = (ctx: Ctx, state: EditorState, node: Node, globalNode: Node[]) => { + const serializer = ctx.get(serializerCtx); + const doc = state.schema.topNodeType.create(undefined, [node, ...globalNode]); + + const markdown = serializer(doc); + + return markdown; +}; + +const addPlaceholder = (ctx: Ctx, markdown: string) => { + const config = ctx.get(inlineSyncConfigCtx); + const holePlaceholder = config.placeholderConfig.hole; + + const [firstLine = '', ...rest] = markdown.split('\n\n'); + + const movePlaceholder = (text: string) => config.movePlaceholder(holePlaceholder, text); + + const handleText = pipe(replacePunctuation(holePlaceholder), movePlaceholder, keepLink); + + let text = handleText(firstLine); + const placeholder = calculatePlaceholder(config.placeholderConfig)(text); + + text = text.replace(holePlaceholder, placeholder); + + text = [text, ...rest].join('\n\n'); + + return [text, placeholder] as [markdown: string, placeholder: string]; +}; + +const getNewNode = (ctx: Ctx, text: string) => { + const parser = ctx.get(parserCtx); + const parsed = parser(text); + + if (!parsed) return null; + + return parsed.firstChild; +}; + +const collectGlobalNodes = (ctx: Ctx, state: EditorState) => { + const { globalNodes } = ctx.get(inlineSyncConfigCtx); + const nodes: Node[] = []; + + state.doc.descendants((node) => { + if (globalNodes.includes(node.type.name) || globalNodes.includes(node.type)) { + nodes.push(node); + return false; + } + return; + }); + + return nodes; +}; + +const removeGlobalFromText = (text: string) => text.split('\n\n')[0] || ''; + +export const getContextByState = (ctx: Ctx, state: EditorState): InlineSyncContext | null => { + try { + const globalNode = collectGlobalNodes(ctx, state); + const node = getNodeFromSelection(state); + + const markdown = getMarkdown(ctx, state, node, globalNode); + const [text, placeholder] = addPlaceholder(ctx, markdown); + + const newNode = getNewNode(ctx, text); + + if (!newNode || node.type !== newNode.type) return null; + + // @ts-expect-error hijack the node attribute + newNode.attrs = { ...node.attrs }; + + newNode.descendants((node) => { + const marks = node.marks; + const link = marks.find((mark) => mark.type.name === 'link'); + if (link && node.text?.includes(placeholder) && link.attrs['href'].includes(placeholder)) { + // @ts-expect-error hijack the mark attribute + link.attrs['href'] = link.attrs['href'].replace(placeholder, ''); + } + }); + + return { + text: removeGlobalFromText(text), + prevNode: node, + nextNode: newNode, + placeholder, + }; + } catch { + return null; + } +}; diff --git a/packages/preset-commonmark/src/plugin/inline-sync/index.ts b/packages/preset-commonmark/src/plugin/inline-sync/index.ts new file mode 100644 index 00000000000..067d42098a4 --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/index.ts @@ -0,0 +1,48 @@ +/* Copyright 2021, Milkdown by Mirone. */ +import { Ctx, editorViewCtx } from '@milkdown/core'; +import { Plugin, PluginKey } from '@milkdown/prose/state'; + +import { inlineSyncConfigCtx } from './config'; +import { getContextByState } from './context'; +import { runReplacer } from './replacer'; + +export * from './config'; + +export const inlineSyncPluginKey = new PluginKey('MILKDOWN_INLINE_SYNC'); +export const getInlineSyncPlugin = (ctx: Ctx) => { + const inlineSyncPlugin = new Plugin({ + key: inlineSyncPluginKey, + state: { + init: () => { + return null; + }, + apply: (tr, _value, _oldState, newState) => { + if (!tr.docChanged) return null; + + const meta = tr.getMeta(inlineSyncPluginKey); + if (meta) { + return null; + } + + const context = getContextByState(ctx, newState); + if (!context) return null; + + const { prevNode, nextNode, text } = context; + + const { shouldSyncNode } = ctx.get(inlineSyncConfigCtx); + + if (!shouldSyncNode({ prevNode, nextNode, ctx, tr, text })) return null; + + requestAnimationFrame(() => { + const { dispatch, state } = ctx.get(editorViewCtx); + + runReplacer(ctx, inlineSyncPluginKey, state, dispatch, prevNode.attrs); + }); + + return null; + }, + }, + }); + + return inlineSyncPlugin; +}; diff --git a/packages/preset-commonmark/src/plugin/inline-sync/regexp.ts b/packages/preset-commonmark/src/plugin/inline-sync/regexp.ts new file mode 100644 index 00000000000..f6b88cf7f1a --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/regexp.ts @@ -0,0 +1,6 @@ +/* Copyright 2021, Milkdown by Mirone. */ + +export const linkRegexp = /\[(?((www|https:\/\/|http:\/\/)\S+))]\((?\S+)\)/; + +export const punctuationRegexp = (holePlaceholder: string) => + new RegExp(`\\\\(?=[^\\w\\s${holePlaceholder}\\\\]|_)`, 'g'); diff --git a/packages/preset-commonmark/src/plugin/inline-sync/replacer.ts b/packages/preset-commonmark/src/plugin/inline-sync/replacer.ts new file mode 100644 index 00000000000..38526ff8a1d --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/replacer.ts @@ -0,0 +1,43 @@ +/* Copyright 2021, Milkdown by Mirone. */ +import { Ctx } from '@milkdown/core'; +import { Attrs } from '@milkdown/prose/model'; +import { EditorState, PluginKey, TextSelection, Transaction } from '@milkdown/prose/state'; + +import { inlineSyncConfigCtx } from './config'; +import { getContextByState } from './context'; +import { calcOffset } from './utils'; + +export const runReplacer = ( + ctx: Ctx, + key: PluginKey, + state: EditorState, + dispatch: (tr: Transaction) => void, + attrs: Attrs, +) => { + const { placeholderConfig } = ctx.get(inlineSyncConfigCtx); + const holePlaceholder = placeholderConfig.hole; + // insert a placeholder to restore the selection + let tr = state.tr.setMeta(key, true).insertText(holePlaceholder, state.selection.from); + + const nextState = state.apply(tr); + const context = getContextByState(ctx, nextState); + + if (!context) return; + + const { $from } = nextState.selection; + const from = $from.before(); + const to = $from.after(); + + const offset = calcOffset(context.nextNode, from, context.placeholder); + + tr = tr + .replaceWith(from, to, context.nextNode) + .setNodeMarkup(from, undefined, attrs) + // delete the placeholder + .delete(offset + 1, offset + 2); + + // restore the selection + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(offset + 1))); + + dispatch(tr); +}; diff --git a/packages/preset-commonmark/src/plugin/inline-sync/utils.ts b/packages/preset-commonmark/src/plugin/inline-sync/utils.ts new file mode 100644 index 00000000000..258f97ccdbd --- /dev/null +++ b/packages/preset-commonmark/src/plugin/inline-sync/utils.ts @@ -0,0 +1,73 @@ +/* Copyright 2021, Milkdown by Mirone. */ + +import { Node } from '@milkdown/prose/model'; + +import { SyncNodePlaceholder } from './config'; +import { linkRegexp, punctuationRegexp } from './regexp'; + +export const keepLink = (str: string) => { + let text = str; + let match = text.match(linkRegexp); + while (match && match.groups) { + const { span } = match.groups; + text = text.replace(linkRegexp, span as string); + + match = text.match(linkRegexp); + } + return text; +}; + +export const swap = (text: string, first: number, last: number) => { + const arr = text.split(''); + const temp = arr[first]; + if (arr[first] && arr[last]) { + arr[first] = arr[last] as string; + arr[last] = temp as string; + } + return arr.join('').toString(); +}; + +export const replacePunctuation = (holePlaceholder: string) => (text: string) => + text.replace(punctuationRegexp(holePlaceholder), ''); + +export const calculatePlaceholder = (placeholder: SyncNodePlaceholder) => (text: string) => { + const index = text.indexOf(placeholder.hole); + const left = text.charAt(index - 1); + const right = text.charAt(index + 1); + const notAWord = /[^\w]|_/; + + // cursor on the right + if (!right) { + return placeholder.punctuation; + } + + // cursor on the left + if (!left) { + return placeholder.char; + } + + if (notAWord.test(left) && notAWord.test(right)) { + return placeholder.punctuation; + } + + return placeholder.char; +}; + +export const calcOffset = (node: Node, from: number, placeholder: string) => { + let offset = from; + let find = false; + node.descendants((n) => { + if (find) return false; + if (n.isText) { + const i = n.text?.indexOf(placeholder); + if (i != null && i >= 0) { + find = true; + offset += i; + return false; + } + } + offset += n.nodeSize; + return; + }); + return offset; +};