mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
feat: Support image resize in message signature (#8042)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="editor-root">
|
<div ref="editorRoot" class="editor-root relative">
|
||||||
<tag-agents
|
<tag-agents
|
||||||
v-if="showUserMentions && isPrivate"
|
v-if="showUserMentions && isPrivate"
|
||||||
:search-key="mentionSearchKey"
|
:search-key="mentionSearchKey"
|
||||||
@@ -23,6 +23,23 @@
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
<div ref="editor" />
|
<div ref="editor" />
|
||||||
|
<div
|
||||||
|
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||||
|
class="absolute shadow-md rounded-[4px] flex gap-1 py-1 px-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50"
|
||||||
|
:style="{
|
||||||
|
top: toolbarPosition.top,
|
||||||
|
left: toolbarPosition.left,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="size in sizes"
|
||||||
|
:key="size.name"
|
||||||
|
class="text-xs font-medium rounded-[4px] border border-solid border-slate-200 dark:border-slate-600 px-1.5 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
@click="setURLWithQueryAndImageSize(size)"
|
||||||
|
>
|
||||||
|
{{ size.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,6 +68,8 @@ import {
|
|||||||
removeSignature,
|
removeSignature,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
scrollCursorIntoView,
|
scrollCursorIntoView,
|
||||||
|
findNodeToInsertImage,
|
||||||
|
setURLWithQueryAndSize,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
@@ -70,8 +89,10 @@ import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
|||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import alertMixin from 'shared/mixins/alertMixin';
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
|
import {
|
||||||
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
MESSAGE_EDITOR_MENU_OPTIONS,
|
||||||
|
MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||||
|
} from 'dashboard/constants/editor';
|
||||||
|
|
||||||
const createState = (
|
const createState = (
|
||||||
content,
|
content,
|
||||||
@@ -114,6 +135,7 @@ export default {
|
|||||||
// allowSignature is a kill switch, ensuring no signature methods
|
// allowSignature is a kill switch, ensuring no signature methods
|
||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -126,9 +148,16 @@ export default {
|
|||||||
editorView: null,
|
editorView: null,
|
||||||
range: null,
|
range: null,
|
||||||
state: undefined,
|
state: undefined,
|
||||||
|
isImageNodeSelected: false,
|
||||||
|
toolbarPosition: { top: 0, left: 0 },
|
||||||
|
sizes: MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||||
|
selectedImageNode: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
editorRoot() {
|
||||||
|
return this.$refs.editorRoot;
|
||||||
|
},
|
||||||
contentFromEditor() {
|
contentFromEditor() {
|
||||||
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
|
return MessageMarkdownSerializer.serialize(this.editorView.state.doc);
|
||||||
},
|
},
|
||||||
@@ -412,6 +441,9 @@ export default {
|
|||||||
focus: () => {
|
focus: () => {
|
||||||
this.onFocus();
|
this.onFocus();
|
||||||
},
|
},
|
||||||
|
click: () => {
|
||||||
|
// this.isEditorMouseFocusedOnAnImage(); Enable it when the backend supports for message resize is done.
|
||||||
|
},
|
||||||
blur: () => {
|
blur: () => {
|
||||||
this.onBlur();
|
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() {
|
isEnterToSendEnabled() {
|
||||||
return isEditorHotKeyEnabled(this.uiSettings, 'enter');
|
return isEditorHotKeyEnabled(this.uiSettings, 'enter');
|
||||||
},
|
},
|
||||||
@@ -588,6 +664,7 @@ export default {
|
|||||||
() => this.resetTyping(),
|
() => this.resetTyping(),
|
||||||
TYPING_INDICATOR_IDLE_TIME
|
TYPING_INDICATOR_IDLE_TIME
|
||||||
);
|
);
|
||||||
|
// this.updateImgToolbarOnDelete(); Enable it when the backend supports for message resize is done.
|
||||||
},
|
},
|
||||||
onKeydown(event) {
|
onKeydown(event) {
|
||||||
if (this.isEnterToSendEnabled()) {
|
if (this.isEnterToSendEnabled()) {
|
||||||
|
|||||||
@@ -32,3 +32,22 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|||||||
'imageUpload',
|
'imageUpload',
|
||||||
'code',
|
'code',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||||
|
{
|
||||||
|
name: 'Small',
|
||||||
|
height: '24px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Medium',
|
||||||
|
height: '48px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Large',
|
||||||
|
height: '72px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Original Size',
|
||||||
|
height: 'auto',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -215,3 +215,69 @@ export function insertAtCursor(editorView, node, from, to) {
|
|||||||
|
|
||||||
return state;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
cleanSignature,
|
cleanSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
|
findNodeToInsertImage,
|
||||||
|
setURLWithQueryAndSize,
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
import { EditorState } from 'prosemirror-state';
|
import { EditorState } from 'prosemirror-state';
|
||||||
import { EditorView } from 'prosemirror-view';
|
import { EditorView } from 'prosemirror-view';
|
||||||
@@ -273,3 +275,167 @@ describe('insertAtCursor', () => {
|
|||||||
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="updateSignature()">
|
|
||||||
<div class="profile--settings--row text-black-900 dark:text-slate-300 row">
|
<div class="profile--settings--row text-black-900 dark:text-slate-300 row">
|
||||||
<div class="w-[25%] py-4 pr-6 ml-0">
|
<div class="w-[25%] py-4 pr-6 ml-0">
|
||||||
<h4 class="block-title text-black-900 dark:text-slate-200">
|
<h4 class="block-title text-black-900 dark:text-slate-200">
|
||||||
@@ -22,19 +21,20 @@
|
|||||||
"
|
"
|
||||||
:enabled-menu-options="customEditorMenuList"
|
:enabled-menu-options="customEditorMenuList"
|
||||||
:enable-suggestions="false"
|
:enable-suggestions="false"
|
||||||
|
:show-image-resize-toolbar="true"
|
||||||
@blur="$v.messageSignature.$touch"
|
@blur="$v.messageSignature.$touch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<woot-button
|
<woot-button
|
||||||
:is-loading="isUpdating"
|
:is-loading="isUpdating"
|
||||||
type="submit"
|
type="button"
|
||||||
:is-disabled="$v.messageSignature.$invalid"
|
:is-disabled="$v.messageSignature.$invalid"
|
||||||
|
@click.prevent="updateSignature()"
|
||||||
>
|
>
|
||||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
|
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
|
||||||
</woot-button>
|
</woot-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braid/vue-formulate": "^2.5.2",
|
"@braid/vue-formulate": "^2.5.2",
|
||||||
"@chatwoot/prosemirror-schema": "^1.0.1",
|
"@chatwoot/prosemirror-schema": "1.0.3",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -3156,10 +3156,10 @@
|
|||||||
"@braid/vue-formulate-i18n" "^1.16.0"
|
"@braid/vue-formulate-i18n" "^1.16.0"
|
||||||
is-plain-object "^3.0.1"
|
is-plain-object "^3.0.1"
|
||||||
|
|
||||||
"@chatwoot/prosemirror-schema@^1.0.1":
|
"@chatwoot/prosemirror-schema@1.0.3":
|
||||||
version "1.0.1"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.1.tgz#7beb7a9303fbf5281d3a7e6c470ac78cce21be04"
|
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.3.tgz#8599e517d42cb31fabf977554ade666eb6316ef0"
|
||||||
integrity sha512-fnT2zSzAmiAh1ElEWqBhISavGZF73DLUzQyml0iiXDy6IU7HS3TtQPI/AGoCK3CkCxvoqBtzhRLGZrHsftoQ9w==
|
integrity sha512-BIVxV7c8x0vbWtWxGPk/VnBrtC1CV0TzZd+GPZC49irVcQQ2vAwgOYaZ/1qcFe9M3jv0kWAWOPqQAfbB5RBB7g==
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it-sup "^1.0.0"
|
markdown-it-sup "^1.0.0"
|
||||||
prosemirror-commands "^1.1.4"
|
prosemirror-commands "^1.1.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user