From a0d8274ffe9f02c18014f94586030ed478652f4b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 4 Sep 2024 11:26:00 +0530 Subject: [PATCH 1/6] feat: Update `triggerCharacters` plugin to support min characters (#30) --- package.json | 2 +- src/mentions/plugin.js | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 57df957..aab8794 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.0.11", + "version": "1.0.13", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.js", "scripts": { diff --git a/src/mentions/plugin.js b/src/mentions/plugin.js index ab72630..b34209d 100644 --- a/src/mentions/plugin.js +++ b/src/mentions/plugin.js @@ -6,29 +6,41 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; -export const triggerCharacters = char => $position => { - const regexp = new RegExp(`(?:^)?${char}[^\\s${char}]*`, 'g'); +/** + * Creates a function to detect if the trigger character followed by a specified number of characters + * has been typed, starting from a new word or after a space. + * @param {string} char - The trigger character to detect. + * @param {number} [minChars=0] - The minimum number of characters that should follow the trigger character. + * @returns {Function} A function that takes a position object and returns true if the condition is met. + */ +export const triggerCharacters = (char, minChars = 0) => $position => { + // Regular expression to find occurrences of 'char' followed by at least 'minChars' non-space characters. + // It matches these sequences starting from the beginning of the text or after a space. + const regexp = new RegExp(`(^|\\s)(${char}[^\\s${char}]{${minChars},})`, 'g'); + // Get the position before the current cursor position in the document. const textFrom = $position.before(); + // Get the position at the end of the current node. const textTo = $position.end(); + // Get the text between the start of the node and the cursor position. const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0'); let match; // eslint-disable-next-line while ((match = regexp.exec(text))) { - const prefix = match.input.slice(Math.max(0, match.index - 1), match.index); - if (!/^[\s\0]?$/.test(prefix)) { - // eslint-disable-next-line - continue; - } + const beforeChar = match[1]; // Will be empty at start of text, or a space in the middle + const fullMatch = match[2]; // Includes the trigger character and following text - const from = match.index + $position.start(); - let to = from + match[0].length; + const from = match.index + $position.start() + beforeChar.length; + const to = from + fullMatch.length; if (from < $position.pos && to >= $position.pos) { - return { range: { from, to }, text: match[0] }; + const trimmedText = fullMatch + ? fullMatch.slice(char.length).trim() + : ""; // Remove trigger char and trim + return { range: { from, to }, text: trimmedText }; } } return null; From c475afcac8f384c61e68e1e276a234406b9385f8 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Fri, 6 Sep 2024 11:39:55 +0530 Subject: [PATCH 2/6] fix: Trigger characters not working after a new space(Shift+Enter) --- package.json | 2 +- src/mentions/plugin.js | 169 +++++++++++++++++++++-------------------- 2 files changed, 87 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index aab8794..a9ffc88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.0.13", + "version": "1.0.14", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.js", "scripts": { diff --git a/src/mentions/plugin.js b/src/mentions/plugin.js index b34209d..12a3d62 100644 --- a/src/mentions/plugin.js +++ b/src/mentions/plugin.js @@ -8,15 +8,15 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; /** * Creates a function to detect if the trigger character followed by a specified number of characters - * has been typed, starting from a new word or after a space. + * has been typed, starting from a new word, after a space, or after a newline. * @param {string} char - The trigger character to detect. * @param {number} [minChars=0] - The minimum number of characters that should follow the trigger character. - * @returns {Function} A function that takes a position object and returns true if the condition is met. + * @returns {Function} A function that takes a position object and returns the match if the condition is met. */ export const triggerCharacters = (char, minChars = 0) => $position => { // Regular expression to find occurrences of 'char' followed by at least 'minChars' non-space characters. // It matches these sequences starting from the beginning of the text or after a space. - const regexp = new RegExp(`(^|\\s)(${char}[^\\s${char}]{${minChars},})`, 'g'); + const regexp = new RegExp(`(?:^)?${char}[^\\s${char}]{${minChars},}`, 'g'); // Get the position before the current cursor position in the document. const textFrom = $position.before(); @@ -30,16 +30,21 @@ export const triggerCharacters = (char, minChars = 0) => $position => { // eslint-disable-next-line while ((match = regexp.exec(text))) { - const beforeChar = match[1]; // Will be empty at start of text, or a space in the middle - const fullMatch = match[2]; // Includes the trigger character and following text + // Check if the character before the match is a space, start of string, or null character + const prefix = match.input.slice(Math.max(0, match.index - 1), match.index); + if (!/^[\s\0]?$/.test(prefix)) { + // If the prefix is not empty, space, or null, skip this match + // eslint-disable-next-line + continue; + } - const from = match.index + $position.start() + beforeChar.length; - const to = from + fullMatch.length; + const from = match.index + $position.start(); + const to = from + match[0].length; if (from < $position.pos && to >= $position.pos) { - const trimmedText = fullMatch - ? fullMatch.slice(char.length).trim() - : ""; // Remove trigger char and trim + const fullMatch = match[0]; + // Remove trigger char and trim + const trimmedText = fullMatch ? fullMatch.slice(char.length) : ''; return { range: { from, to }, text: trimmedText }; } } @@ -53,96 +58,94 @@ export const suggestionsPlugin = ({ onChange = () => false, onExit = () => false, onKeyDown = () => false, -}) => { - return new Plugin({ - key: new PluginKey('mentions'), +}) => new Plugin({ + key: new PluginKey('mentions'), - view() { - return { - update: (view, prevState) => { - const prev = this.key.getState(prevState); - const next = this.key.getState(view.state); + view() { + return { + update: (view, prevState) => { + const prev = this.key.getState(prevState); + const next = this.key.getState(view.state); - const moved = + const moved = prev.active && next.active && prev.range.from !== next.range.from; - const started = !prev.active && next.active; - const stopped = prev.active && !next.active; - const changed = !started && !stopped && prev.text !== next.text; - - if (stopped || moved) - onExit({ view, range: prev.range, text: prev.text }); - if (changed && !moved) - onChange({ view, range: next.range, text: next.text }); - if (started || moved) - onEnter({ view, range: next.range, text: next.text }); - }, + const started = !prev.active && next.active; + const stopped = prev.active && !next.active; + const changed = !started && !stopped && prev.text !== next.text; + + if (stopped || moved) + onExit({ view, range: prev.range, text: prev.text }); + if (changed && !moved) + onChange({ view, range: next.range, text: next.text }); + if (started || moved) + onEnter({ view, range: next.range, text: next.text }); + }, + }; + }, + + state: { + init() { + return { + active: false, + range: {}, + text: null, }; }, - state: { - init() { - return { - active: false, - range: {}, - text: null, - }; - }, - - apply(tr, prev) { - const { selection } = tr; - const next = { ...prev }; + apply(tr, prev) { + const { selection } = tr; + const next = { ...prev }; - if (selection.from === selection.to) { - if ( - selection.from < prev.range.from || + if (selection.from === selection.to) { + if ( + selection.from < prev.range.from || selection.from > prev.range.to - ) { - next.active = false; - } - - const $position = selection.$from; - const match = matcher($position); - - if (match) { - next.active = true; - next.range = match.range; - next.text = match.text; - } else { - next.active = false; - } - } else { + ) { next.active = false; } - if (!next.active) { - next.range = {}; - next.text = null; + const $position = selection.$from; + const match = matcher($position); + + if (match) { + next.active = true; + next.range = match.range; + next.text = match.text; + } else { + next.active = false; } + } else { + next.active = false; + } - return next; - }, + if (!next.active) { + next.range = {}; + next.text = null; + } + + return next; }, + }, - props: { - handleKeyDown(view, event) { - const { active } = this.getState(view.state); + props: { + handleKeyDown(view, event) { + const { active } = this.getState(view.state); - if (!active) return false; + if (!active) return false; - return onKeyDown({ view, event }); - }, - decorations(editorState) { - const { active, range } = this.getState(editorState); + return onKeyDown({ view, event }); + }, + decorations(editorState) { + const { active, range } = this.getState(editorState); - if (!active) return null; + if (!active) return null; - return DecorationSet.create(editorState.doc, [ - Decoration.inline(range.from, range.to, { - nodeName: 'span', - class: suggestionClass, - }), - ]); - }, + return DecorationSet.create(editorState.doc, [ + Decoration.inline(range.from, range.to, { + nodeName: 'span', + class: suggestionClass, + }), + ]); }, - }); -}; + }, +}); From 76b1326bd66c1e3eb8497ef086f2b19aae871666 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Fri, 6 Sep 2024 11:42:59 +0530 Subject: [PATCH 3/6] chore: Update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9ffc88..bdd0c4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.0.14", + "version": "1.0.15", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.js", "scripts": { From 2a2ce8ac66eff1e015104af558db329ec9af598e Mon Sep 17 00:00:00 2001 From: iamsivin Date: Fri, 6 Sep 2024 11:50:06 +0530 Subject: [PATCH 4/6] chore: Revert un used changes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bdd0c4e..d5d17fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.0.15", + "version": "1.0.16", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.js", "scripts": { From a8b354791f71d1f00fde43aa1c918a60fd9c45f0 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Fri, 6 Sep 2024 11:51:25 +0530 Subject: [PATCH 5/6] chore: Minor fix --- src/mentions/plugin.js | 144 +++++++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/src/mentions/plugin.js b/src/mentions/plugin.js index 12a3d62..443aeaa 100644 --- a/src/mentions/plugin.js +++ b/src/mentions/plugin.js @@ -58,94 +58,96 @@ export const suggestionsPlugin = ({ onChange = () => false, onExit = () => false, onKeyDown = () => false, -}) => new Plugin({ - key: new PluginKey('mentions'), +}) => { + return new Plugin({ + key: new PluginKey('mentions'), - view() { - return { - update: (view, prevState) => { - const prev = this.key.getState(prevState); - const next = this.key.getState(view.state); + view() { + return { + update: (view, prevState) => { + const prev = this.key.getState(prevState); + const next = this.key.getState(view.state); - const moved = + const moved = prev.active && next.active && prev.range.from !== next.range.from; - const started = !prev.active && next.active; - const stopped = prev.active && !next.active; - const changed = !started && !stopped && prev.text !== next.text; - - if (stopped || moved) - onExit({ view, range: prev.range, text: prev.text }); - if (changed && !moved) - onChange({ view, range: next.range, text: next.text }); - if (started || moved) - onEnter({ view, range: next.range, text: next.text }); - }, - }; - }, - - state: { - init() { - return { - active: false, - range: {}, - text: null, + const started = !prev.active && next.active; + const stopped = prev.active && !next.active; + const changed = !started && !stopped && prev.text !== next.text; + + if (stopped || moved) + onExit({ view, range: prev.range, text: prev.text }); + if (changed && !moved) + onChange({ view, range: next.range, text: next.text }); + if (started || moved) + onEnter({ view, range: next.range, text: next.text }); + }, }; }, - apply(tr, prev) { - const { selection } = tr; - const next = { ...prev }; - - if (selection.from === selection.to) { - if ( - selection.from < prev.range.from || - selection.from > prev.range.to - ) { - next.active = false; - } + state: { + init() { + return { + active: false, + range: {}, + text: null, + }; + }, - const $position = selection.$from; - const match = matcher($position); + apply(tr, prev) { + const { selection } = tr; + const next = { ...prev }; - if (match) { - next.active = true; - next.range = match.range; - next.text = match.text; + if (selection.from === selection.to) { + if ( + selection.from < prev.range.from || + selection.from > prev.range.to + ) { + next.active = false; + } + + const $position = selection.$from; + const match = matcher($position); + + if (match) { + next.active = true; + next.range = match.range; + next.text = match.text; + } else { + next.active = false; + } } else { next.active = false; } - } else { - next.active = false; - } - if (!next.active) { - next.range = {}; - next.text = null; - } + if (!next.active) { + next.range = {}; + next.text = null; + } - return next; + return next; + }, }, - }, - props: { - handleKeyDown(view, event) { - const { active } = this.getState(view.state); + props: { + handleKeyDown(view, event) { + const { active } = this.getState(view.state); - if (!active) return false; + if (!active) return false; - return onKeyDown({ view, event }); - }, - decorations(editorState) { - const { active, range } = this.getState(editorState); + return onKeyDown({ view, event }); + }, + decorations(editorState) { + const { active, range } = this.getState(editorState); - if (!active) return null; + if (!active) return null; - return DecorationSet.create(editorState.doc, [ - Decoration.inline(range.from, range.to, { - nodeName: 'span', - class: suggestionClass, - }), - ]); + return DecorationSet.create(editorState.doc, [ + Decoration.inline(range.from, range.to, { + nodeName: 'span', + class: suggestionClass, + }), + ]); + }, }, - }, -}); + }); +}; From f92276a9d600eafb92e4ef81e879377ffc936859 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 11 Sep 2024 10:58:13 +0530 Subject: [PATCH 6/6] feat: Add image paste plugin (#31) --- package.json | 2 +- src/plugins/image.js | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/plugins/image.js diff --git a/package.json b/package.json index d5d17fa..e593515 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/prosemirror-schema", - "version": "1.0.16", + "version": "1.0.17", "description": "Schema setup for using prosemirror in chatwoot. Based on 👉 https://github.com/ProseMirror/prosemirror-example-setup/", "main": "dist/index.js", "scripts": { diff --git a/src/plugins/image.js b/src/plugins/image.js new file mode 100644 index 0000000..ea93254 --- /dev/null +++ b/src/plugins/image.js @@ -0,0 +1,58 @@ +import { Plugin } from "prosemirror-state"; + +/** + * Replaces an image node in the editor with a new image URL. + * + * @param {string} currentUrl - The current URL of the image to be replaced. + * @param {string} newUrl - The new URL to replace the current image with. + * @param {EditorView} view - The ProseMirror editor view. + */ +function replaceImage(currentUrl, newUrl, view) { + view.state.doc.descendants((node, pos) => { + if (node.type.name === "image" && node.attrs.src === currentUrl) { + const tr = view.state.tr.setNodeMarkup(pos, null, { + ...node.attrs, + src: newUrl, + }); + view.dispatch(tr); + } + }); +} + +/** + * Creates a ProseMirror plugin that handles image pasting and uploading. + * + * @param {Function} uploadImage - A function that takes an image URL and returns a Promise + * that resolves to the new URL after uploading. + * @returns {Plugin} A ProseMirror plugin that handles image pasting. + */ +const imagePastePlugin = (uploadImage) => + new Plugin({ + props: { + /** + * Handles the paste event in the editor. + * + * @param {EditorView} view - The ProseMirror editor view. + * @param {Event} event - The paste event. + * @param {Slice} slice - The ProseMirror Slice object representing the pasted content. + */ + handlePaste(view, event, slice) { + const imageUrls = []; + slice.content.descendants((node) => { + if (node.type.name === "image") { + imageUrls.push(node.attrs.src); + } + }); + Promise.all(imageUrls.map(async (url) => { + try { + const newUrl = await uploadImage(url); + replaceImage(url, newUrl, view); + } catch (error) { + console.error("Error uploading image:", error); + } + })); + }, + }, + }); + +export default imagePastePlugin;