diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 850ca5f4b..86aced65d 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -56,6 +56,8 @@ import {
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
+ parseClipboardText,
+ serializeClipboardText,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -656,6 +658,8 @@ function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
+ clipboardTextParser: parseClipboardText,
+ clipboardTextSerializer: serializeClipboardText,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index e126a2766..87b09c4a3 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -8,6 +8,7 @@ import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
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.
@@ -589,3 +590,78 @@ export function calculateMenuPosition(coords, rect, isRtl) {
}
/* 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 */
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index abbef420f..8bb942d32 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -15,6 +15,8 @@ import {
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
+ parseClipboardText,
+ serializeClipboardText,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
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
line2
+ expect(result.content.child(0).childCount).toBe(3);
+ // Second paragraph: line3
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
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');
+ });
+ });
+});