From fe6a3e7491f6a42123d3d8a92ab588f2a40d7799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20K=C3=BChn?= Date: Thu, 9 Sep 2021 23:51:05 +0200 Subject: [PATCH] feat: add getText() and generateText() methods (fix #1428) (#1875) * move getTextBetween method * add getText method * refactoring * refactoring * refactoring * move renderText to schema, add generateText method * add GenerateText demo * docs: update * remove demo from html page --- .../GuideContent/GenerateText/Vue/index.html | 15 +++++ .../GenerateText/Vue/index.spec.js | 7 +++ .../GuideContent/GenerateText/Vue/index.vue | 59 +++++++++++++++++++ docs/src/docPages/api/editor.md | 1 + packages/core/src/Editor.ts | 24 ++++++++ packages/core/src/ExtensionManager.ts | 34 ++--------- packages/core/src/Node.ts | 5 +- .../src/extensions/clipboardTextSerializer.ts | 43 +++----------- packages/core/src/helpers/generateText.ts | 29 +++++++++ .../helpers/getSchemaByResolvedExtensions.ts | 6 ++ packages/core/src/helpers/getText.ts | 18 ++++++ packages/core/src/helpers/getTextBetween.ts | 45 ++++++++++++++ .../helpers/getTextSeralizersFromSchema.ts | 9 +++ packages/core/src/index.ts | 3 + packages/core/src/types.ts | 7 +++ 15 files changed, 241 insertions(+), 64 deletions(-) create mode 100644 demos/src/GuideContent/GenerateText/Vue/index.html create mode 100644 demos/src/GuideContent/GenerateText/Vue/index.spec.js create mode 100644 demos/src/GuideContent/GenerateText/Vue/index.vue create mode 100644 packages/core/src/helpers/generateText.ts create mode 100644 packages/core/src/helpers/getText.ts create mode 100644 packages/core/src/helpers/getTextBetween.ts create mode 100644 packages/core/src/helpers/getTextSeralizersFromSchema.ts diff --git a/demos/src/GuideContent/GenerateText/Vue/index.html b/demos/src/GuideContent/GenerateText/Vue/index.html new file mode 100644 index 00000000..3a14b395 --- /dev/null +++ b/demos/src/GuideContent/GenerateText/Vue/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/GuideContent/GenerateText/Vue/index.spec.js b/demos/src/GuideContent/GenerateText/Vue/index.spec.js new file mode 100644 index 00000000..af2cc5ed --- /dev/null +++ b/demos/src/GuideContent/GenerateText/Vue/index.spec.js @@ -0,0 +1,7 @@ +context('/src/GuideContent/GenerateText/Vue/', () => { + before(() => { + cy.visit('/src/GuideContent/GenerateText/Vue/') + }) + + // TODO: Write tests +}) diff --git a/demos/src/GuideContent/GenerateText/Vue/index.vue b/demos/src/GuideContent/GenerateText/Vue/index.vue new file mode 100644 index 00000000..a3532c0d --- /dev/null +++ b/demos/src/GuideContent/GenerateText/Vue/index.vue @@ -0,0 +1,59 @@ + + + diff --git a/docs/src/docPages/api/editor.md b/docs/src/docPages/api/editor.md index 0b104de7..057cde27 100644 --- a/docs/src/docPages/api/editor.md +++ b/docs/src/docPages/api/editor.md @@ -21,6 +21,7 @@ Don’t confuse methods with [commands](/api/commands). Commands are used to cha | `destroy()` | – | Stops the editor instance and unbinds all events. | | `getHTML()` | – | Returns the current content as HTML. | | `getJSON()` | – | Returns the current content as JSON. | +| `getText()` | – | Returns the current content as text. | | `getAttributes()` | `name` Name of the node or mark | Get attributes of the currently selected node or mark. | | `isActive()` | `name` Name of the node or mark
`attrs` Attributes of the node or mark | Returns if the currently selected node or mark is active. | | `isEditable` | - | Returns whether the editor is editable. | diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index 4a35edfb..a6d122b8 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -11,7 +11,9 @@ import isActive from './helpers/isActive' import removeElement from './utilities/removeElement' import createDocument from './helpers/createDocument' import getHTMLFromFragment from './helpers/getHTMLFromFragment' +import getText from './helpers/getText' import isNodeEmpty from './helpers/isNodeEmpty' +import getTextSeralizersFromSchema from './helpers/getTextSeralizersFromSchema' import createStyleTag from './utilities/createStyleTag' import CommandManager from './CommandManager' import ExtensionManager from './ExtensionManager' @@ -21,6 +23,7 @@ import { CanCommands, ChainedCommands, SingleCommands, + TextSerializer, } from './types' import * as extensions from './extensions' import style from './style' @@ -394,6 +397,27 @@ export class Editor extends EventEmitter { return getHTMLFromFragment(this.state.doc, this.schema) } + /** + * Get the document as text. + */ + public getText(options?: { + blockSeparator?: string, + textSerializers?: Record, + }): string { + const { + blockSeparator = '\n\n', + textSerializers = {}, + } = options || {} + + return getText(this.state.doc, { + blockSeparator, + textSerializers: { + ...textSerializers, + ...getTextSeralizersFromSchema(this.schema), + }, + }) + } + /** * Check if there is no content. */ diff --git a/packages/core/src/ExtensionManager.ts b/packages/core/src/ExtensionManager.ts index 07c96d66..78bf15fd 100644 --- a/packages/core/src/ExtensionManager.ts +++ b/packages/core/src/ExtensionManager.ts @@ -4,7 +4,12 @@ import { inputRules as inputRulesPlugin } from 'prosemirror-inputrules' import { EditorView, Decoration } from 'prosemirror-view' import { Plugin } from 'prosemirror-state' import { Editor } from './Editor' -import { Extensions, RawCommands, AnyConfig } from './types' +import { + Extensions, + RawCommands, + AnyConfig, + TextSerializer, +} from './types' import getExtensionField from './helpers/getExtensionField' import getSchemaByResolvedExtensions from './helpers/getSchemaByResolvedExtensions' import getSchemaTypeByName from './helpers/getSchemaTypeByName' @@ -330,31 +335,4 @@ export default class ExtensionManager { return [extension.name, nodeview] })) } - - get textSerializers() { - const { editor } = this - const { nodeExtensions } = splitExtensions(this.extensions) - - return Object.fromEntries(nodeExtensions - .filter(extension => !!getExtensionField(extension, 'renderText')) - .map(extension => { - const context = { - name: extension.name, - options: extension.options, - editor, - type: getNodeType(extension.name, this.schema), - } - - const renderText = getExtensionField(extension, 'renderText', context) - - if (!renderText) { - return [] - } - - const textSerializer = (props: { node: ProsemirrorNode }) => renderText(props) - - return [extension.name, textSerializer] - })) - } - } diff --git a/packages/core/src/Node.ts b/packages/core/src/Node.ts index 82236614..ce901592 100644 --- a/packages/core/src/Node.ts +++ b/packages/core/src/Node.ts @@ -379,12 +379,13 @@ declare module '@tiptap/core' { this: { name: string, options: Options, - editor: Editor, - type: NodeType, parent: ParentConfig>['renderText'], }, props: { node: ProseMirrorNode, + pos: number, + parent: ProseMirrorNode, + index: number, } ) => string) | null, diff --git a/packages/core/src/extensions/clipboardTextSerializer.ts b/packages/core/src/extensions/clipboardTextSerializer.ts index e9208671..8a861821 100644 --- a/packages/core/src/extensions/clipboardTextSerializer.ts +++ b/packages/core/src/extensions/clipboardTextSerializer.ts @@ -1,37 +1,6 @@ -import { Editor } from '@tiptap/core' import { Plugin, PluginKey } from 'prosemirror-state' import { Extension } from '../Extension' - -const textBetween = ( - editor: Editor, - from: number, - to: number, - blockSeparator?: string, - leafText?: string, -): string => { - let text = '' - let separated = true - - editor.state.doc.nodesBetween(from, to, (node, pos) => { - const textSerializer = editor.extensionManager.textSerializers[node.type.name] - - if (textSerializer) { - text += textSerializer({ node }) - separated = !blockSeparator - } else if (node.isText) { - text += node?.text?.slice(Math.max(from, pos) - pos, to - pos) - separated = !blockSeparator - } else if (node.isLeaf && leafText) { - text += leafText - separated = !blockSeparator - } else if (!separated && node.isBlock) { - text += blockSeparator - separated = true - } - }, 0) - - return text -} +import getTextBetween from '../helpers/getTextBetween' export const ClipboardTextSerializer = Extension.create({ name: 'editable', @@ -43,9 +12,15 @@ export const ClipboardTextSerializer = Extension.create({ props: { clipboardTextSerializer: () => { const { editor } = this - const { from, to } = editor.state.selection + const { state, extensionManager } = editor + const { doc, selection } = state + const { from, to } = selection + const { textSerializers } = extensionManager + const range = { from, to } - return textBetween(editor, from, to, '\n') + return getTextBetween(doc, range, { + textSerializers, + }) }, }, }), diff --git a/packages/core/src/helpers/generateText.ts b/packages/core/src/helpers/generateText.ts new file mode 100644 index 00000000..300f745b --- /dev/null +++ b/packages/core/src/helpers/generateText.ts @@ -0,0 +1,29 @@ +import { Node } from 'prosemirror-model' +import getSchema from './getSchema' +import { Extensions, JSONContent, TextSerializer } from '../types' +import getTextSeralizersFromSchema from './getTextSeralizersFromSchema' +import getText from './getText' + +export default function generateText( + doc: JSONContent, + extensions: Extensions, + options?: { + blockSeparator?: string, + textSerializers?: Record, + }, +): string { + const { + blockSeparator = '\n\n', + textSerializers = {}, + } = options || {} + const schema = getSchema(extensions) + const contentNode = Node.fromJSON(schema, doc) + + return getText(contentNode, { + blockSeparator, + textSerializers: { + ...textSerializers, + ...getTextSeralizersFromSchema(schema), + }, + }) +} diff --git a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts index 62c017db..fb1d9f21 100644 --- a/packages/core/src/helpers/getSchemaByResolvedExtensions.ts +++ b/packages/core/src/helpers/getSchemaByResolvedExtensions.ts @@ -77,6 +77,12 @@ export default function getSchemaByResolvedExtensions(extensions: Extensions): S }) } + const renderText = getExtensionField(extension, 'renderText', context) + + if (renderText) { + schema.toText = renderText + } + return [extension.name, schema] })) diff --git a/packages/core/src/helpers/getText.ts b/packages/core/src/helpers/getText.ts new file mode 100644 index 00000000..94b2489b --- /dev/null +++ b/packages/core/src/helpers/getText.ts @@ -0,0 +1,18 @@ +import { TextSerializer } from '../types' +import { Node as ProseMirrorNode } from 'prosemirror-model' +import getTextBetween from './getTextBetween' + +export default function getText( + node: ProseMirrorNode, + options?: { + blockSeparator?: string, + textSerializers?: Record, + }, +) { + const range = { + from: 0, + to: node.content.size, + } + + return getTextBetween(node, range, options) +} diff --git a/packages/core/src/helpers/getTextBetween.ts b/packages/core/src/helpers/getTextBetween.ts new file mode 100644 index 00000000..cd1233b1 --- /dev/null +++ b/packages/core/src/helpers/getTextBetween.ts @@ -0,0 +1,45 @@ +import { Range, TextSerializer } from '../types' +import { Node as ProseMirrorNode } from 'prosemirror-model' + +export default function getTextBetween( + startNode: ProseMirrorNode, + range: Range, + options?: { + blockSeparator?: string, + textSerializers?: Record, + }, +): string { + const { from, to } = range + const { + blockSeparator = '\n\n', + textSerializers = {}, + } = options || {} + let text = '' + let separated = true + + startNode.nodesBetween(from, to, (node, pos, parent, index) => { + const textSerializer = textSerializers?.[node.type.name] + + if (textSerializer) { + if (node.isBlock && !separated) { + text += blockSeparator + separated = true + } + + text += textSerializer({ + node, + pos, + parent, + index, + }) + } else if (node.isText) { + text += node?.text?.slice(Math.max(from, pos) - pos, to - pos) + separated = false + } else if (node.isBlock && !separated) { + text += blockSeparator + separated = true + } + }) + + return text +} diff --git a/packages/core/src/helpers/getTextSeralizersFromSchema.ts b/packages/core/src/helpers/getTextSeralizersFromSchema.ts new file mode 100644 index 00000000..fc9b16ef --- /dev/null +++ b/packages/core/src/helpers/getTextSeralizersFromSchema.ts @@ -0,0 +1,9 @@ +import { Schema } from 'prosemirror-model' +import { TextSerializer } from '../types' + +export default function getTextSeralizersFromSchema(schema: Schema): Record { + return Object.fromEntries(Object + .entries(schema.nodes) + .filter(([, node]) => node.spec.toText) + .map(([name, node]) => [name, node.spec.toText])) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa0f4342..184b9dd6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { default as findParentNode } from './helpers/findParentNode' export { default as findParentNodeClosestToPos } from './helpers/findParentNodeClosestToPos' export { default as generateHTML } from './helpers/generateHTML' export { default as generateJSON } from './helpers/generateJSON' +export { default as generateText } from './helpers/generateText' export { default as getSchema } from './helpers/getSchema' export { default as getHTMLFromFragment } from './helpers/getHTMLFromFragment' export { default as getDebugJSON } from './helpers/getDebugJSON' @@ -30,6 +31,8 @@ export { default as getMarkType } from './helpers/getMarkType' export { default as getMarksBetween } from './helpers/getMarksBetween' export { default as getNodeAttributes } from './helpers/getNodeAttributes' export { default as getNodeType } from './helpers/getNodeType' +export { default as getText } from './helpers/getText' +export { default as getTextBetween } from './helpers/getTextBetween' export { default as isActive } from './helpers/isActive' export { default as isList } from './helpers/isList' export { default as isMarkActive } from './helpers/isMarkActive' diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3fcba20..b039d458 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -210,3 +210,10 @@ export type NodeWithPos = { node: ProseMirrorNode, pos: number, } + +export type TextSerializer = (props: { + node: ProseMirrorNode, + pos: number, + parent: ProseMirrorNode, + index: number, +}) => string