From 5daa870b0906f0387fe07041681bc6f5b3774617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philipp=20K=C3=BChn?= Date: Wed, 8 Dec 2021 21:26:30 +0100 Subject: [PATCH] feat: add some improvements to `CharacterCount` extension (#2256), fix #1049, fix #1550, fix #1839, fix #2245 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix a bug when exceeding the character limit * find a better way to limit the doc size * check paste events * add storage method * refactoring * use textBetween instead of textContent * return early if no limit is set * add words method to storage * show word count in charactercount demo Co-authored-by: Philipp Kühn --- demos/src/Examples/Community/React/index.jsx | 6 +- demos/src/Examples/Community/Vue/index.vue | 6 +- .../Extensions/CharacterCount/React/index.jsx | 4 +- .../Extensions/CharacterCount/Vue/index.vue | 4 +- docs/api/editor.md | 13 +- docs/api/extensions/character-count.md | 52 ++++++-- packages/core/src/Editor.ts | 4 + .../src/character-count.ts | 126 ++++++++++++++++-- 8 files changed, 170 insertions(+), 45 deletions(-) diff --git a/demos/src/Examples/Community/React/index.jsx b/demos/src/Examples/Community/React/index.jsx index 51bd1e33..ec0e7fc7 100644 --- a/demos/src/Examples/Community/React/index.jsx +++ b/demos/src/Examples/Community/React/index.jsx @@ -34,14 +34,14 @@ export default () => { }) const percentage = editor - ? Math.round((100 / limit) * editor.getCharacterCount()) + ? Math.round((100 / limit) * editor.storage.characterCount.characters()) : 0 return (
{editor - &&
+ &&
{
- {editor.getCharacterCount()}/{limit} characters + {editor.storage.characterCount.characters()}/{limit} characters
} diff --git a/demos/src/Examples/Community/Vue/index.vue b/demos/src/Examples/Community/Vue/index.vue index 95d60eb8..5b571a09 100644 --- a/demos/src/Examples/Community/Vue/index.vue +++ b/demos/src/Examples/Community/Vue/index.vue @@ -2,7 +2,7 @@
-
+
- {{ editor.getCharacterCount() }}/{{ limit }} characters + {{ editor.storage.characterCount.characters() }}/{{ limit }} characters
@@ -87,7 +87,7 @@ export default { computed: { percentage() { - return Math.round((100 / this.limit) * this.editor.getCharacterCount()) + return Math.round((100 / this.limit) * this.editor.storage.characterCount.characters()) }, }, diff --git a/demos/src/Extensions/CharacterCount/React/index.jsx b/demos/src/Extensions/CharacterCount/React/index.jsx index f236ef15..8cf4c88b 100644 --- a/demos/src/Extensions/CharacterCount/React/index.jsx +++ b/demos/src/Extensions/CharacterCount/React/index.jsx @@ -34,7 +34,9 @@ export default () => {
- {editor.getCharacterCount()}/{limit} characters + {editor.storage.characterCount.characters()}/{limit} characters +
+ {editor.storage.characterCount.words()} words
) diff --git a/demos/src/Extensions/CharacterCount/Vue/index.vue b/demos/src/Extensions/CharacterCount/Vue/index.vue index f3980042..c7123c77 100644 --- a/demos/src/Extensions/CharacterCount/Vue/index.vue +++ b/demos/src/Extensions/CharacterCount/Vue/index.vue @@ -3,7 +3,9 @@
- {{ editor.getCharacterCount() }}/{{ limit }} characters + {{ editor.storage.characterCount.characters() }}/{{ limit }} characters +
+ {{ editor.storage.characterCount.words() }} words
diff --git a/docs/api/editor.md b/docs/api/editor.md index 7755a053..6b01fac4 100644 --- a/docs/api/editor.md +++ b/docs/api/editor.md @@ -92,13 +92,6 @@ editor.isActive('heading', { level: 2 }) editor.isActive({ textAlign: 'justify' }) ``` -### getCharacterCount() -Get the number of characters for the current document. - -```js -editor.getCharacterCount() -``` - ### registerPlugin() Register a ProseMirror plugin. @@ -124,14 +117,14 @@ editor.setOptions({ }, }) ``` - + ### setEditable() Update editable state of the editor. - + | Parameter | Type | Description | | --------- | ------- | ------------------------------------------------------------- | | editable | boolean | `true` when the user should be able to write into the editor. | - + ```js // Make the editor read-only editor.setEditable(false) diff --git a/docs/api/extensions/character-count.md b/docs/api/extensions/character-count.md index c82ab1a5..683f529c 100644 --- a/docs/api/extensions/character-count.md +++ b/docs/api/extensions/character-count.md @@ -18,7 +18,7 @@ npm install @tiptap/extension-character-count ### limit -The maximum number of characters that should be allowed. | +The maximum number of characters that should be allowed. Default: `0` @@ -28,21 +28,45 @@ CharacterCount.configure({ }) ``` +### mode + +The mode by which the size is calculated. + +Default: `'textSize'` + +```js +CharacterCount.configure({ + mode: 'nodeSize', +}) +``` + +## Storage + +### characters() +Get the number of characters for the current document. + +```js +editor.storage.characterCount.characters() + +// Get the size of a specific node. +editor.storage.characterCount.characters({ node: someCustomNode }) + +// Overwrite the default `mode`. +editor.storage.characterCount.characters({ mode: 'nodeSize' }) +``` + +### words() +Get the number of words for the current document. + +```js +editor.storage.characterCount.words() + +// Get the number of words for a specific node. +editor.storage.characterCount.words({ node: someCustomNode }) +``` + ## Source code [packages/extension-character-count/](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-character-count/) ## Usage https://embed.tiptap.dev/preview/Extensions/CharacterCount - -## Count words, emojis, letters … -Want to count words instead? Or emojis? Or the letter *a*? Sure, no problem. You can access the `textContent` directly and count whatever you’re into. - -```js -new Editor({ - onUpdate({ editor }) { - const wordCount = editor.state.doc.textContent.split(' ').length - - console.log(wordCount) - }, -}) -``` diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index e3cf3f0f..f23c64a7 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -449,8 +449,12 @@ export class Editor extends EventEmitter { /** * Get the number of characters for the current document. + * + * @deprecated */ public getCharacterCount(): number { + console.warn('[tiptap warn]: "editor.getCharacterCount()" is deprecated. Please use "editor.storage.characterCount.characters()" instead.') + return this.state.doc.content.size - 2 } diff --git a/packages/extension-character-count/src/character-count.ts b/packages/extension-character-count/src/character-count.ts index 5e4c4a46..2aee88d0 100644 --- a/packages/extension-character-count/src/character-count.ts +++ b/packages/extension-character-count/src/character-count.ts @@ -1,35 +1,135 @@ import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from 'prosemirror-state' - -export const pluginKey = new PluginKey('characterLimit') +import { Node as ProseMirrorNode } from 'prosemirror-model' export interface CharacterCountOptions { - limit?: number, + /** + * The maximum number of characters that should be allowed. Defaults to `0`. + */ + limit: number, + /** + * The mode by which the size is calculated. Defaults to 'textSize'. + */ + mode: 'textSize' | 'nodeSize', } -export const CharacterCount = Extension.create({ +export interface CharacterCountStorage { + /** + * Get the number of characters for the current document. + */ + characters?: (options: { + node?: ProseMirrorNode, + mode?: 'textSize' | 'nodeSize', + }) => number, + + /** + * Get the number of words for the current document. + */ + words?: (options: { + node?: ProseMirrorNode, + }) => number, +} + +export const CharacterCount = Extension.create({ name: 'characterCount', addOptions() { return { limit: 0, + mode: 'textSize', + } + }, + + addStorage() { + return { + characters: undefined, + words: undefined, + } + }, + + onBeforeCreate() { + this.storage.characters = options => { + const node = options?.node || this.editor.state.doc + const mode = options?.mode || this.options.mode + + if (mode === 'textSize') { + const text = node.textBetween(0, node.content.size, undefined, ' ') + + return text.length + } + + return node.nodeSize + } + + this.storage.words = options => { + const node = options?.node || this.editor.state.doc + const text = node.textBetween(0, node.content.size, undefined, ' ') + const words = text + .split(' ') + .filter(word => word !== '') + + return words.length } }, addProseMirrorPlugins() { - const { options } = this - return [ new Plugin({ + key: new PluginKey('characterCount'), + filterTransaction: (transaction, state) => { + const limit = this.options.limit - key: pluginKey, - - appendTransaction: (transactions, oldState, newState) => { - const length = newState.doc.content.size - - if (options.limit && length > options.limit) { - return newState.tr.insertText('', options.limit + 1, length) + // Nothing has changed or no limit is defined. Ignore it. + if (!transaction.docChanged || limit === 0) { + return true } + + const oldSize = this.storage.characters?.({ node: state.doc }) || 0 + const newSize = this.storage.characters?.({ node: transaction.doc }) || 0 + + // Everything is in the limit. Good. + if (newSize <= limit) { + return true + } + + // The limit has already been exceeded but will be reduced. + if (oldSize > limit && newSize > limit && newSize <= oldSize) { + return true + } + + // The limit has already been exceeded and will be increased further. + if (oldSize > limit && newSize > limit && newSize > oldSize) { + return false + } + + const isPaste = transaction.getMeta('paste') + + // Block all exceeding transactions that were not pasted. + if (!isPaste) { + return false + } + + // For pasted content, we try to remove the exceeding content. + const pos = transaction.selection.$head.pos + const over = newSize - limit + const from = pos - over + const to = pos + + // It’s probably a bad idea to mutate transactions within `filterTransaction` + // but for now this is working fine. + transaction.deleteRange(from, to) + + // In some situations, the limit will continue to be exceeded after trimming. + // This happens e.g. when truncating within a complex node (e.g. table) + // and ProseMirror has to close this node again. + // If this is the case, we prevent the transaction completely. + const updatedSize = this.storage.characters?.({ node: transaction.doc }) || 0 + + if (updatedSize > limit) { + return false + } + + return true }, }), ]