From 86ca90aa159d55683b93526bf44cd95fdddbeabe Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:20:12 +0530 Subject: [PATCH] feat: Support image resize in message signature (#8042) --- .../components/widgets/WootWriter/Editor.vue | 83 ++++++++- app/javascript/dashboard/constants/editor.js | 19 ++ .../dashboard/helper/editorHelper.js | 66 +++++++ .../dashboard/helper/messageEditorHelper.js | 40 ----- .../helper/specs/editorHelper.spec.js | 166 ++++++++++++++++++ .../helper/specs/messageEditorHelper.spec.js | 100 ----------- .../settings/profile/MessageSignature.vue | 70 ++++---- package.json | 2 +- yarn.lock | 8 +- 9 files changed, 371 insertions(+), 183 deletions(-) delete mode 100644 app/javascript/dashboard/helper/messageEditorHelper.js delete mode 100644 app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 235d9f34c..b1c0fd5c5 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -1,5 +1,5 @@ @@ -51,6 +68,8 @@ import { removeSignature, insertAtCursor, scrollCursorIntoView, + findNodeToInsertImage, + setURLWithQueryAndSize, } from 'dashboard/helper/editorHelper'; const TYPING_INDICATOR_IDLE_TIME = 4000; @@ -70,8 +89,10 @@ import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; import alertMixin from 'shared/mixins/alertMixin'; -import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper'; -import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; +import { + MESSAGE_EDITOR_MENU_OPTIONS, + MESSAGE_EDITOR_IMAGE_RESIZES, +} from 'dashboard/constants/editor'; const createState = ( content, @@ -114,6 +135,7 @@ export default { // allowSignature is a kill switch, ensuring no signature methods // are triggered except when this flag is true allowSignature: { type: Boolean, default: false }, + showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar }, data() { return { @@ -126,9 +148,16 @@ export default { editorView: null, range: null, state: undefined, + isImageNodeSelected: false, + toolbarPosition: { top: 0, left: 0 }, + sizes: MESSAGE_EDITOR_IMAGE_RESIZES, + selectedImageNode: null, }; }, computed: { + editorRoot() { + return this.$refs.editorRoot; + }, contentFromEditor() { return MessageMarkdownSerializer.serialize(this.editorView.state.doc); }, @@ -412,6 +441,9 @@ export default { focus: () => { this.onFocus(); }, + click: () => { + // this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done. + }, blur: () => { this.onBlur(); }, @@ -424,6 +456,50 @@ export default { }, }); }, + isEditorMouseFocusedOnAnImage() { + if (!this.showImageResizeToolbar) { + return; + } + this.selectedImageNode = document.querySelector( + 'img.ProseMirror-selectednode' + ); + if (this.selectedImageNode) { + this.isImageNodeSelected = !!this.selectedImageNode; + // Get the position of the selected node + this.setToolbarPosition(); + } else { + this.isImageNodeSelected = false; + } + }, + setToolbarPosition() { + const editorRect = this.editorRoot.getBoundingClientRect(); + const rect = this.selectedImageNode.getBoundingClientRect(); + this.toolbarPosition = { + top: `${rect.top - editorRect.top - 30}px`, + left: `${rect.left - editorRect.left - 4}px`, + }; + }, + setURLWithQueryAndImageSize(size) { + if (!this.showImageResizeToolbar) { + return; + } + setURLWithQueryAndSize(this.selectedImageNode, size, this.editorView); + this.isImageNodeSelected = false; + }, + updateImgToolbarOnDelete() { + // check if the selected node is present or not on keyup + // this is needed because the user can select an image and then delete it + // in that case, the selected node will be null and we need to hide the toolbar + // otherwise, the toolbar will be visible even when the image is deleted and cause some errors + if (this.selectedImageNode) { + const hasImgSelectedNode = document.querySelector( + 'img.ProseMirror-selectednode' + ); + if (!hasImgSelectedNode) { + this.isImageNodeSelected = false; + } + } + }, isEnterToSendEnabled() { return isEditorHotKeyEnabled(this.uiSettings, 'enter'); }, @@ -588,6 +664,7 @@ export default { () => this.resetTyping(), TYPING_INDICATOR_IDLE_TIME ); + // this.updateImgToolbarOnDelete(); Enable it when the backend supports for message resize is done. }, onKeydown(event) { if (this.isEnterToSendEnabled()) { diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 517063425..157ec46ae 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -32,3 +32,22 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'imageUpload', 'code', ]; + +export const MESSAGE_EDITOR_IMAGE_RESIZES = [ + { + name: 'Small', + height: '24px', + }, + { + name: 'Medium', + height: '48px', + }, + { + name: 'Large', + height: '72px', + }, + { + name: 'Original Size', + height: 'auto', + }, +]; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 0b5171b32..0403b62fe 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -215,3 +215,69 @@ export function insertAtCursor(editorView, node, from, to) { return state; } + +/** + * Determines the appropriate node and position to insert an image in the editor. + * + * Based on the current editor state and the provided image URL, this function finds out the correct node (either + * a standalone image node or an image wrapped in a paragraph) and its respective position in the editor. + * + * 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it. + * 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped + * in a new paragraph and then inserted. + * 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it. + * + * @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc. + * @param {string} fileUrl - The URL of the image to be inserted into the editor. + * @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created. + * @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image). + * @property {number} pos - The position where the new node should be inserted in the editor. + */ + +export const findNodeToInsertImage = (editorState, fileUrl) => { + const { selection, schema } = editorState; + const { nodes } = schema; + const currentNode = selection.$from.node(); + const { + type: { name: typeName }, + content: { size, content }, + } = currentNode; + + const imageNode = nodes.image.create({ src: fileUrl }); + + if (!imageNode) return null; + + const isInParagraph = typeName === 'paragraph'; + const needsNewLine = + !content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0; + + return { + node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode), + pos: selection.from + needsNewLine, + }; +}; + +/** + * Set URL with query and size. + * + * @param {Object} selectedImageNode - The current selected node. + * @param {Object} size - The size to set. + * @param {Object} editorView - The editor view. + */ +export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { + if (selectedImageNode) { + // Create and apply the transaction + const tr = editorView.state.tr.setNodeMarkup( + editorView.state.selection.from, + null, + { + src: selectedImageNode.src, + height: size.height, + } + ); + + if (tr.docChanged) { + editorView.dispatch(tr); + } + } +} diff --git a/app/javascript/dashboard/helper/messageEditorHelper.js b/app/javascript/dashboard/helper/messageEditorHelper.js deleted file mode 100644 index 1355f734a..000000000 --- a/app/javascript/dashboard/helper/messageEditorHelper.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Determines the appropriate node and position to insert an image in the editor. - * - * Based on the current editor state and the provided image URL, this function finds out the correct node (either - * a standalone image node or an image wrapped in a paragraph) and its respective position in the editor. - * - * 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it. - * 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped - * in a new paragraph and then inserted. - * 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it. - * - * @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc. - * @param {string} fileUrl - The URL of the image to be inserted into the editor. - * @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created. - * @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image). - * @property {number} pos - The position where the new node should be inserted in the editor. - */ - -export const findNodeToInsertImage = (editorState, fileUrl) => { - const { selection, schema } = editorState; - const { nodes } = schema; - const currentNode = selection.$from.node(); - const { - type: { name: typeName }, - content: { size, content }, - } = currentNode; - - const imageNode = nodes.image.create({ src: fileUrl }); - - if (!imageNode) return null; - - const isInParagraph = typeName === 'paragraph'; - const needsNewLine = - !content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0; - - return { - node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode), - pos: selection.from + needsNewLine, - }; -}; diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index b99a1156f..55d0fe853 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -6,6 +6,8 @@ import { cleanSignature, extractTextFromMarkdown, insertAtCursor, + findNodeToInsertImage, + setURLWithQueryAndSize, } from '../editorHelper'; import { EditorState } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; @@ -273,3 +275,167 @@ describe('insertAtCursor', () => { expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me'); }); }); + +describe('findNodeToInsertImage', () => { + let mockEditorState; + + beforeEach(() => { + mockEditorState = { + selection: { + $from: { + node: jest.fn(() => ({})), + }, + from: 0, + }, + schema: { + nodes: { + image: { + create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })), + }, + paragraph: { + create: jest.fn((_, node) => ({ + type: { name: 'paragraph' }, + content: [node], + })), + }, + }, + }, + }; + }); + + it('should insert image directly into an empty paragraph', () => { + const mockNode = { + type: { name: 'paragraph' }, + content: { size: 0, content: [] }, + }; + mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); + + const result = findNodeToInsertImage(mockEditorState, 'image-url'); + expect(result).toEqual({ + node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, + pos: 0, + }); + }); + + it('should insert image directly into a paragraph without an image but with other content', () => { + const mockNode = { + type: { name: 'paragraph' }, + content: { + size: 1, + content: [ + { + type: { name: 'text' }, + }, + ], + }, + }; + mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); + mockEditorState.selection.from = 1; + + const result = findNodeToInsertImage(mockEditorState, 'image-url'); + expect(result).toEqual({ + node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, + pos: 2, // Because it should insert after the text, on a new line. + }); + }); + + it("should wrap image in a new paragraph when the current node isn't a paragraph", () => { + const mockNode = { + type: { name: 'not-a-paragraph' }, + content: { size: 0, content: [] }, + }; + mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); + + const result = findNodeToInsertImage(mockEditorState, 'image-url'); + expect(result.node.type.name).toBe('paragraph'); + expect(result.node.content[0].type.name).toBe('image'); + expect(result.node.content[0].attrs.src).toBe('image-url'); + expect(result.pos).toBe(0); + }); + + it('should insert a new image directly into the paragraph that already contains an image', () => { + const mockNode = { + type: { name: 'paragraph' }, + content: { + size: 1, + content: [ + { + type: { name: 'image', attrs: { src: 'existing-image-url' } }, + }, + ], + }, + }; + mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); + mockEditorState.selection.from = 1; + + const result = findNodeToInsertImage(mockEditorState, 'image-url'); + expect(result.node.type.name).toBe('image'); + expect(result.node.attrs.src).toBe('image-url'); + expect(result.pos).toBe(1); + }); +}); + +describe('setURLWithQueryAndSize', () => { + let selectedNode; + let editorView; + + beforeEach(() => { + selectedNode = { + setAttribute: jest.fn(), + }; + + const tr = { + setNodeMarkup: jest.fn().mockReturnValue({ + docChanged: true, + }), + }; + + const state = { + selection: { from: 0 }, + tr, + }; + + editorView = { + state, + dispatch: jest.fn(), + }; + }); + + it('updates the URL with the given size and updates the editor view', () => { + const size = { height: '20px' }; + + setURLWithQueryAndSize(selectedNode, size, editorView); + + // Check if the editor view is updated + expect(editorView.dispatch).toHaveBeenCalledTimes(1); + }); + + it('updates the URL with the given size and updates the editor view with original size', () => { + const size = { height: 'auto' }; + + setURLWithQueryAndSize(selectedNode, size, editorView); + + // Check if the editor view is updated + expect(editorView.dispatch).toHaveBeenCalledTimes(1); + }); + + it('does not update the editor view if the document has not changed', () => { + editorView.state.tr.setNodeMarkup = jest.fn().mockReturnValue({ + docChanged: false, + }); + + const size = { height: '20px' }; + + setURLWithQueryAndSize(selectedNode, size, editorView); + + // Check if the editor view dispatch was not called + expect(editorView.dispatch).not.toHaveBeenCalled(); + }); + + it('does not perform any operations if selectedNode is not provided', () => { + setURLWithQueryAndSize(null, { height: '20px' }, editorView); + + // Ensure the dispatch method wasn't called + expect(editorView.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js b/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js deleted file mode 100644 index 3d4b6719c..000000000 --- a/app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { findNodeToInsertImage } from '../messageEditorHelper'; - -describe('findNodeToInsertImage', () => { - let mockEditorState; - - beforeEach(() => { - mockEditorState = { - selection: { - $from: { - node: jest.fn(() => ({})), - }, - from: 0, - }, - schema: { - nodes: { - image: { - create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })), - }, - paragraph: { - create: jest.fn((_, node) => ({ - type: { name: 'paragraph' }, - content: [node], - })), - }, - }, - }, - }; - }); - - it('should insert image directly into an empty paragraph', () => { - const mockNode = { - type: { name: 'paragraph' }, - content: { size: 0, content: [] }, - }; - mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); - - const result = findNodeToInsertImage(mockEditorState, 'image-url'); - expect(result).toEqual({ - node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, - pos: 0, - }); - }); - - it('should insert image directly into a paragraph without an image but with other content', () => { - const mockNode = { - type: { name: 'paragraph' }, - content: { - size: 1, - content: [ - { - type: { name: 'text' }, - }, - ], - }, - }; - mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); - mockEditorState.selection.from = 1; - - const result = findNodeToInsertImage(mockEditorState, 'image-url'); - expect(result).toEqual({ - node: { type: { name: 'image' }, attrs: { src: 'image-url' } }, - pos: 2, // Because it should insert after the text, on a new line. - }); - }); - - it("should wrap image in a new paragraph when the current node isn't a paragraph", () => { - const mockNode = { - type: { name: 'not-a-paragraph' }, - content: { size: 0, content: [] }, - }; - mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); - - const result = findNodeToInsertImage(mockEditorState, 'image-url'); - expect(result.node.type.name).toBe('paragraph'); - expect(result.node.content[0].type.name).toBe('image'); - expect(result.node.content[0].attrs.src).toBe('image-url'); - expect(result.pos).toBe(0); - }); - - it('should insert a new image directly into the paragraph that already contains an image', () => { - const mockNode = { - type: { name: 'paragraph' }, - content: { - size: 1, - content: [ - { - type: { name: 'image', attrs: { src: 'existing-image-url' } }, - }, - ], - }, - }; - mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode); - mockEditorState.selection.from = 1; - - const result = findNodeToInsertImage(mockEditorState, 'image-url'); - expect(result.node.type.name).toBe('image'); - expect(result.node.attrs.src).toBe('image-url'); - expect(result.pos).toBe(1); - }); -}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index 92c570121..af87c8d59 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -1,40 +1,40 @@