feat: Adds image support for message signature (#7827)

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sivin Varghese
2023-09-11 10:23:45 +05:30
committed by GitHub
parent 6c39aed882
commit e39d19b1e8
8 changed files with 217 additions and 5 deletions

View File

@@ -15,6 +15,13 @@
:search-key="variableSearchTerm" :search-key="variableSearchTerm"
@click="insertVariable" @click="insertVariable"
/> />
<input
ref="imageUpload"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
hidden
@change="onFileChange"
/>
<div ref="editor" /> <div ref="editor" />
</div> </div>
</template> </template>
@@ -39,6 +46,7 @@ import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList'; import VariableList from '../conversation/VariableList';
const TYPING_INDICATOR_IDLE_TIME = 4000; const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
@@ -51,12 +59,17 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils'; import { replaceVariablesInMessage } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; 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 } from 'dashboard/constants/editor';
const createState = ( const createState = (
content, content,
placeholder, placeholder,
plugins = [], plugins = [],
methods = {},
enabledMenuOptions enabledMenuOptions
) => { ) => {
return EditorState.create({ return EditorState.create({
@@ -64,6 +77,7 @@ const createState = (
plugins: buildEditor({ plugins: buildEditor({
schema: messageSchema, schema: messageSchema,
placeholder, placeholder,
methods,
plugins, plugins,
enabledMenuOptions, enabledMenuOptions,
}), }),
@@ -73,7 +87,7 @@ const createState = (
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList }, components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin], mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
@@ -255,6 +269,7 @@ export default {
this.value, this.value,
this.placeholder, this.placeholder,
this.plugins, this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions this.editorMenuOptions
); );
}, },
@@ -269,6 +284,7 @@ export default {
content, content,
this.placeholder, this.placeholder,
this.plugins, this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions this.editorMenuOptions
); );
this.editorView.updateState(this.state); this.editorView.updateState(this.state);
@@ -397,6 +413,57 @@ export default {
tr.scrollIntoView(); tr.scrollIntoView();
return false; return false;
}, },
openFileBrowser() {
this.$refs.imageUpload.click();
},
onFileChange() {
const file = this.$refs.imageUpload.files[0];
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadImageToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
this.$refs.imageUpload.value = '';
},
async uploadImageToStorage(file) {
try {
const { fileUrl } = await uploadFile(file);
if (fileUrl) {
this.onImageInsertInEditor(fileUrl);
}
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
)
);
} catch (error) {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
)
);
}
},
onImageInsertInEditor(fileUrl) {
const { tr } = this.editorView.state;
const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);
if (insertData) {
this.editorView.dispatch(
tr.insert(insertData.pos, insertData.node).scrollIntoView()
);
this.focusEditorInputField();
}
},
emitOnChange() { emitOnChange() {
this.editorView.updateState(this.state); this.editorView.updateState(this.state);

View File

@@ -15,6 +15,7 @@ export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'link', 'link',
'undo', 'undo',
'redo', 'redo',
'imageUpload',
]; ];
export const ARTICLE_EDITOR_MENU_OPTIONS = [ export const ARTICLE_EDITOR_MENU_OPTIONS = [

View File

@@ -0,0 +1,40 @@
/**
* 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,
};
};

View File

@@ -0,0 +1,100 @@
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);
});
});

View File

@@ -39,7 +39,10 @@
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.", "NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature", "BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again", "API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully" "API_SUCCESS": "Signature saved successfully",
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
}, },
"MESSAGE_SIGNATURE": { "MESSAGE_SIGNATURE": {
"LABEL": "Message Signature", "LABEL": "Message Signature",

View File

@@ -101,6 +101,7 @@ export default {
} }
} finally { } finally {
this.isUpdating = false; this.isUpdating = false;
this.initValues();
this.showAlert(this.errorMessage); this.showAlert(this.errorMessage);
} }
}, },

View File

@@ -30,7 +30,7 @@
], ],
"dependencies": { "dependencies": {
"@braid/vue-formulate": "^2.5.2", "@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0",
"@chatwoot/utils": "^0.0.16", "@chatwoot/utils": "^0.0.16",
"@hcaptcha/vue-hcaptcha": "^0.3.2", "@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5", "@june-so/analytics-next": "^1.36.5",

View File

@@ -2994,9 +2994,9 @@
is-url "^1.2.4" is-url "^1.2.4"
nanoid "^2.1.11" nanoid "^2.1.11"
"@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449": "@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0":
version "1.0.0" version "1.0.0"
resolved "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449" resolved "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0"
dependencies: dependencies:
markdown-it-sup "^1.0.0" markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4" prosemirror-commands "^1.1.4"