From cbc4e0fa9bb9842fc3c724659107db7568749b53 Mon Sep 17 00:00:00 2001 From: iamsivin Date: Sat, 10 Jan 2026 21:46:43 +0530 Subject: [PATCH] fix: Preserve hard breaks and paragraphs on paste --- .../components/widgets/WootWriter/Editor.vue | 4 + .../dashboard/helper/editorHelper.js | 76 ++++++ .../helper/specs/editorHelper.spec.js | 229 ++++++++++++++++++ 3 files changed, 309 insertions(+) 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'); + }); + }); +});