Skip to content

Commit

Permalink
feat: Adds image support for message signature (#7827)
Browse files Browse the repository at this point in the history
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
  • Loading branch information
iamsivin and scmmishra committed Sep 11, 2023
1 parent 6c39aed commit e39d19b
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 5 deletions.
Expand Up @@ -15,6 +15,13 @@
:search-key="variableSearchTerm"
@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>
</template>
Expand All @@ -39,6 +46,7 @@ import CannedResponse from '../conversation/CannedResponse';
import VariableList from '../conversation/VariableList';
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import {
hasPressedEnterAndNotCmdOrShift,
Expand All @@ -51,19 +59,25 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { replaceVariablesInMessage } from '@chatwoot/utils';
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';
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
Expand All @@ -73,7 +87,7 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin],
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
Expand Down Expand Up @@ -255,6 +269,7 @@ export default {
this.value,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
},
Expand All @@ -269,6 +284,7 @@ export default {
content,
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
this.editorMenuOptions
);
this.editorView.updateState(this.state);
Expand Down Expand Up @@ -397,6 +413,57 @@ export default {
tr.scrollIntoView();
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() {
this.editorView.updateState(this.state);
Expand Down
1 change: 1 addition & 0 deletions app/javascript/dashboard/constants/editor.js
Expand Up @@ -15,6 +15,7 @@ export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'link',
'undo',
'redo',
'imageUpload',
];

export const ARTICLE_EDITOR_MENU_OPTIONS = [
Expand Down
40 changes: 40 additions & 0 deletions app/javascript/dashboard/helper/messageEditorHelper.js
@@ -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,
};
};
100 changes: 100 additions & 0 deletions app/javascript/dashboard/helper/specs/messageEditorHelper.spec.js
@@ -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);
});
});
5 changes: 4 additions & 1 deletion app/javascript/dashboard/i18n/locale/en/settings.json
Expand Up @@ -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.",
"BTN_TEXT": "Save message signature",
"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": {
"LABEL": "Message Signature",
Expand Down
Expand Up @@ -101,6 +101,7 @@ export default {
}
} finally {
this.isUpdating = false;
this.initValues();
this.showAlert(this.errorMessage);
}
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -30,7 +30,7 @@
],
"dependencies": {
"@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",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
Expand Down
4 changes: 2 additions & 2 deletions yarn.lock
Expand Up @@ -2994,9 +2994,9 @@
is-url "^1.2.4"
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"
resolved "https://github.com/chatwoot/prosemirror-schema.git#5f6ec0888948e7b16f64c1f2779114162e8fa449"
resolved "https://github.com/chatwoot/prosemirror-schema.git#825755b53f1ef4ae14fac2393b53a02e14ce21e0"
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"
Expand Down

0 comments on commit e39d19b

Please sign in to comment.