Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7966a5adce | ||
|
|
4648ecfbda | ||
|
|
cbc4e0fa9b | ||
|
|
ed9006f450 | ||
|
|
ef16a4220d | ||
|
|
48cdd30304 | ||
|
|
fbfcdf01e7 |
@@ -56,6 +56,8 @@ import {
|
|||||||
calculateMenuPosition,
|
calculateMenuPosition,
|
||||||
getEffectiveChannelType,
|
getEffectiveChannelType,
|
||||||
stripUnsupportedFormatting,
|
stripUnsupportedFormatting,
|
||||||
|
parseClipboardText,
|
||||||
|
serializeClipboardText,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
hasPressedEnterAndNotCmdOrShift,
|
hasPressedEnterAndNotCmdOrShift,
|
||||||
@@ -656,6 +658,8 @@ function createEditorView() {
|
|||||||
editorView = new EditorView(editor.value, {
|
editorView = new EditorView(editor.value, {
|
||||||
state: state,
|
state: state,
|
||||||
editable: () => !props.disabled,
|
editable: () => !props.disabled,
|
||||||
|
clipboardTextParser: parseClipboardText,
|
||||||
|
clipboardTextSerializer: serializeClipboardText,
|
||||||
dispatchTransaction: tx => {
|
dispatchTransaction: tx => {
|
||||||
state = state.apply(tx);
|
state = state.apply(tx);
|
||||||
editorView.updateState(state);
|
editorView.updateState(state);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as Sentry from '@sentry/vue';
|
|||||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
import { Fragment, Slice } from 'prosemirror-model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||||
@@ -589,3 +590,78 @@ export function calculateMenuPosition(coords, rect, isRtl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* End Menu Positioning Helpers */
|
/* End Menu Positioning Helpers */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clipboard Handling Functions
|
||||||
|
* Custom clipboard parsers and serializers for proper newline handling.
|
||||||
|
* Based on ProseMirror best practices from prosemirror-view/src/clipboard.ts
|
||||||
|
*
|
||||||
|
* Default ProseMirror behavior:
|
||||||
|
* - Paste: splits by \n+ and creates paragraph for each line (no hard breaks)
|
||||||
|
* - Copy: uses textBetween with "\n\n" (no distinction between hard break and paragraph)
|
||||||
|
*
|
||||||
|
* Our custom behavior:
|
||||||
|
* - Paste: single \n → hard_break, double \n\n → paragraph break
|
||||||
|
* - Copy: hard_break → \n, paragraph → \n\n
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses plain text from clipboard into ProseMirror document structure.
|
||||||
|
* Single \n → hard_break, double \n\n → paragraph break.
|
||||||
|
*
|
||||||
|
* @param {string} text - Plain text from clipboard
|
||||||
|
* @param {ResolvedPos} $context - Resolved position where paste occurs
|
||||||
|
* @returns {Slice|null} ProseMirror Slice or null if schema lacks required nodes
|
||||||
|
*/
|
||||||
|
export function parseClipboardText(text, $context) {
|
||||||
|
const schema = $context.parent.type.schema;
|
||||||
|
const { hard_break: hardBreak, paragraph } = schema.nodes;
|
||||||
|
|
||||||
|
if (!hardBreak || !paragraph) return null;
|
||||||
|
|
||||||
|
// Normalize all line endings to \n first
|
||||||
|
const normalized = text.replace(/\r\n?/g, '\n');
|
||||||
|
const content = [];
|
||||||
|
|
||||||
|
// Split by double+ newlines for paragraphs
|
||||||
|
normalized.split(/\n{2,}/).forEach(block => {
|
||||||
|
if (!block.trim()) return;
|
||||||
|
|
||||||
|
// Split by single newlines for hard breaks within paragraph
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const inlineContent = [];
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (line) inlineContent.push(schema.text(line));
|
||||||
|
if (i < lines.length - 1) inlineContent.push(hardBreak.create());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inlineContent.length) {
|
||||||
|
content.push(paragraph.create(null, inlineContent));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Slice(
|
||||||
|
Fragment.from(content.length ? content : [paragraph.create()]),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes ProseMirror content to plain text for clipboard.
|
||||||
|
* hard_break → \n, paragraph break → \n\n
|
||||||
|
* https://discuss.prosemirror.net/t/parsing-single-line-breaks-into-hard-line-breaks-instead-of-paragraphs/4824/2
|
||||||
|
*
|
||||||
|
* @param {Slice} slice - ProseMirror Slice to serialize
|
||||||
|
* @param {EditorView} view - Editor view instance
|
||||||
|
* @returns {string} Plain text with proper newlines
|
||||||
|
*/
|
||||||
|
export function serializeClipboardText(slice, view) {
|
||||||
|
const { hard_break: hardBreak } = view.state.schema.nodes;
|
||||||
|
return slice.content.textBetween(0, slice.content.size, '\n\n', node =>
|
||||||
|
hardBreak && node.type === hardBreak ? '\n' : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* End Clipboard Handling Functions */
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
getMenuAnchor,
|
getMenuAnchor,
|
||||||
calculateMenuPosition,
|
calculateMenuPosition,
|
||||||
stripUnsupportedFormatting,
|
stripUnsupportedFormatting,
|
||||||
|
parseClipboardText,
|
||||||
|
serializeClipboardText,
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
import { FORMATTING } from 'dashboard/constants/editor';
|
import { FORMATTING } from 'dashboard/constants/editor';
|
||||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||||
@@ -1155,3 +1157,230 @@ describe('Menu positioning helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Clipboard handling functions', () => {
|
||||||
|
// Create a schema with hard_break support for testing
|
||||||
|
const clipboardSchema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'paragraph+' },
|
||||||
|
paragraph: {
|
||||||
|
content: 'inline*',
|
||||||
|
group: 'block',
|
||||||
|
toDOM: () => ['p', 0],
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
group: 'inline',
|
||||||
|
},
|
||||||
|
hard_break: {
|
||||||
|
inline: true,
|
||||||
|
group: 'inline',
|
||||||
|
selectable: false,
|
||||||
|
toDOM: () => ['br'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseClipboardText', () => {
|
||||||
|
it('converts single newlines to hard_break nodes', () => {
|
||||||
|
const text = 'line1\nline2';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(1); // One paragraph
|
||||||
|
const para = result.content.child(0);
|
||||||
|
expect(para.childCount).toBe(3); // text, hard_break, text
|
||||||
|
expect(para.child(0).text).toBe('line1');
|
||||||
|
expect(para.child(1).type.name).toBe('hard_break');
|
||||||
|
expect(para.child(2).text).toBe('line2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts double newlines to paragraph breaks', () => {
|
||||||
|
const text = 'para1\n\npara2';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(2); // Two paragraphs
|
||||||
|
expect(result.content.child(0).textContent).toBe('para1');
|
||||||
|
expect(result.content.child(1).textContent).toBe('para2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed single and double newlines', () => {
|
||||||
|
const text = 'line1\nline2\n\nline3\nline4';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(2); // Two paragraphs
|
||||||
|
// First paragraph: line1<br>line2
|
||||||
|
expect(result.content.child(0).childCount).toBe(3);
|
||||||
|
// Second paragraph: line3<br>line4
|
||||||
|
expect(result.content.child(1).childCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty text by creating empty paragraph', () => {
|
||||||
|
const text = '';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(1);
|
||||||
|
expect(result.content.child(0).childCount).toBe(0); // Empty paragraph
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Windows line endings (CRLF)', () => {
|
||||||
|
// Single CRLF should create hard break, double CRLF should create paragraph
|
||||||
|
const text = 'line1\r\nline2';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(1); // One paragraph
|
||||||
|
expect(result.content.child(0).childCount).toBe(3); // line1<br>line2
|
||||||
|
expect(result.content.child(0).child(0).text).toBe('line1');
|
||||||
|
expect(result.content.child(0).child(1).type.name).toBe('hard_break');
|
||||||
|
expect(result.content.child(0).child(2).text).toBe('line2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Windows double line endings (CRLF CRLF) as paragraph break', () => {
|
||||||
|
const text = 'para1\r\n\r\npara2';
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [
|
||||||
|
clipboardSchema.nodes.paragraph.create(),
|
||||||
|
]);
|
||||||
|
const $context = doc.resolve(1);
|
||||||
|
|
||||||
|
const result = parseClipboardText(text, $context);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.content.childCount).toBe(2); // Two paragraphs
|
||||||
|
expect(result.content.child(0).textContent).toBe('para1');
|
||||||
|
expect(result.content.child(1).textContent).toBe('para2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null if schema lacks hard_break or paragraph', () => {
|
||||||
|
const minimalSchema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'text*' },
|
||||||
|
text: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const doc = minimalSchema.nodes.doc.create();
|
||||||
|
const $context = doc.resolve(0);
|
||||||
|
|
||||||
|
const result = parseClipboardText('test', $context);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serializeClipboardText', () => {
|
||||||
|
it('converts hard_break nodes to single newlines', () => {
|
||||||
|
const para = clipboardSchema.nodes.paragraph.create(null, [
|
||||||
|
clipboardSchema.text('line1'),
|
||||||
|
clipboardSchema.nodes.hard_break.create(),
|
||||||
|
clipboardSchema.text('line2'),
|
||||||
|
]);
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [para]);
|
||||||
|
const slice = doc.slice(0, doc.content.size);
|
||||||
|
|
||||||
|
const mockView = {
|
||||||
|
state: {
|
||||||
|
schema: clipboardSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serializeClipboardText(slice, mockView);
|
||||||
|
|
||||||
|
expect(result).toBe('line1\nline2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts paragraph breaks to double newlines', () => {
|
||||||
|
const para1 = clipboardSchema.nodes.paragraph.create(
|
||||||
|
null,
|
||||||
|
clipboardSchema.text('para1')
|
||||||
|
);
|
||||||
|
const para2 = clipboardSchema.nodes.paragraph.create(
|
||||||
|
null,
|
||||||
|
clipboardSchema.text('para2')
|
||||||
|
);
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [para1, para2]);
|
||||||
|
const slice = doc.slice(0, doc.content.size);
|
||||||
|
|
||||||
|
const mockView = {
|
||||||
|
state: {
|
||||||
|
schema: clipboardSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serializeClipboardText(slice, mockView);
|
||||||
|
|
||||||
|
expect(result).toBe('para1\n\npara2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed hard breaks and paragraph breaks', () => {
|
||||||
|
const para1 = clipboardSchema.nodes.paragraph.create(null, [
|
||||||
|
clipboardSchema.text('line1'),
|
||||||
|
clipboardSchema.nodes.hard_break.create(),
|
||||||
|
clipboardSchema.text('line2'),
|
||||||
|
]);
|
||||||
|
const para2 = clipboardSchema.nodes.paragraph.create(null, [
|
||||||
|
clipboardSchema.text('line3'),
|
||||||
|
clipboardSchema.nodes.hard_break.create(),
|
||||||
|
clipboardSchema.text('line4'),
|
||||||
|
]);
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [para1, para2]);
|
||||||
|
const slice = doc.slice(0, doc.content.size);
|
||||||
|
|
||||||
|
const mockView = {
|
||||||
|
state: {
|
||||||
|
schema: clipboardSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serializeClipboardText(slice, mockView);
|
||||||
|
|
||||||
|
expect(result).toBe('line1\nline2\n\nline3\nline4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty paragraphs', () => {
|
||||||
|
const para1 = clipboardSchema.nodes.paragraph.create();
|
||||||
|
const para2 = clipboardSchema.nodes.paragraph.create(
|
||||||
|
null,
|
||||||
|
clipboardSchema.text('text')
|
||||||
|
);
|
||||||
|
const doc = clipboardSchema.nodes.doc.create(null, [para1, para2]);
|
||||||
|
const slice = doc.slice(0, doc.content.size);
|
||||||
|
|
||||||
|
const mockView = {
|
||||||
|
state: {
|
||||||
|
schema: clipboardSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = serializeClipboardText(slice, mockView);
|
||||||
|
|
||||||
|
expect(result).toBe('\n\ntext');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user