From c55f0d74751a21db73918a4feaf7db3008119611 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Tue, 2 Jul 2024 16:25:34 +0100 Subject: [PATCH] Improved paste of single paragraphs into quote nodes ref https://linear.app/tryghost/issue/ENG-1216 - Lexical only allows paste of text nodes onto an empty quote to keep the quote formatting - added paste override when pasting a paragraph onto an empty quote node so it's less likely a copy/paste will unexpectedly clear the formatting --- packages/koenig-lexical/package.json | 1 + .../src/plugins/KoenigBehaviourPlugin.jsx | 41 ++++ .../test/e2e/paste-behaviour.test.js | 206 +++++++++++++++++- 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/packages/koenig-lexical/package.json b/packages/koenig-lexical/package.json index 7866cb252..4f4ba8833 100644 --- a/packages/koenig-lexical/package.json +++ b/packages/koenig-lexical/package.json @@ -59,6 +59,7 @@ "@lexical/selection": "0.14.2", "@lexical/text": "0.14.2", "@lexical/utils": "0.14.2", + "@lexical/html": "0.14.2", "@lezer/highlight": "^1.1.3", "@playwright/test": "^1.33.0", "@prettier/sync": "^0.3.0", diff --git a/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx b/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx index 44142223b..fd6752c3a 100644 --- a/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx +++ b/packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.jsx @@ -42,6 +42,8 @@ import { PASTE_COMMAND, createCommand } from 'lexical'; +import {$generateNodesFromDOM} from '@lexical/html'; +import {$generateNodesFromSerializedNodes, $insertGeneratedNodes} from '@lexical/clipboard'; import {$insertAndSelectNode} from '../utils/$insertAndSelectNode'; import { $isAtStartOfDocument, @@ -1281,6 +1283,45 @@ function useKoenigBehaviour({editor, containerElem, cursorDidExitAtTop, isNested const text = clipboardEvent?.clipboardData?.getData(MIME_TEXT_PLAIN); const html = clipboardEvent?.clipboardData?.getData(MIME_TEXT_HTML); + const lexical = clipboardEvent?.clipboardData?.getData('application/x-lexical-editor'); + + const selection = $getSelection(); + + // if current selection is an empty quote, make sure a + // paste with a single paragraph doesn't clear the quote formatting + const anchorNode = selection.anchor.getNode(); + if (($isQuoteNode(anchorNode) || $isAsideNode(anchorNode)) && anchorNode.isEmpty() && selection && $isRangeSelection(selection) && selection.isCollapsed()) { + let nodes; + + if (lexical) { + const {namespace, nodes: pastedNodes} = JSON.parse(lexical); + if (namespace === 'KoenigEditor') { + // completely empty paragraph nodes can be copied when selection hits end of paragraph, + // exclude those so they don't interfere + const filteredNodes = pastedNodes?.filter?.(n => n.type === 'paragraph' && n.children.length > 0) || []; + + if (filteredNodes.length === 1 && filteredNodes[0].type === 'paragraph') { + nodes = $generateNodesFromSerializedNodes(filteredNodes[0].children); + } + } + } else if (html) { + const dom = new DOMParser().parseFromString(html, 'text/html'); + + dom.querySelectorAll('body > br').forEach(br => br.remove()); + + const pastedNodes = $generateNodesFromDOM(editor, dom); + + if (pastedNodes.length === 1 && $isParagraphNode(pastedNodes[0])) { + nodes = pastedNodes[0].getChildren(); + } + } + + if (nodes && Array.isArray(nodes) && nodes.length > 0) { + $insertGeneratedNodes(editor, nodes, selection); + return true; + } + } + // TODO: replace with better regex to include more protocols like mailto, ftp, etc const linkMatch = text?.match(/^(https?:\/\/[^\s]+)$/); diff --git a/packages/koenig-lexical/test/e2e/paste-behaviour.test.js b/packages/koenig-lexical/test/e2e/paste-behaviour.test.js index 0668f150b..a872fb026 100644 --- a/packages/koenig-lexical/test/e2e/paste-behaviour.test.js +++ b/packages/koenig-lexical/test/e2e/paste-behaviour.test.js @@ -1,5 +1,5 @@ import fs from 'fs'; -import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, paste, pasteHtml, pasteText} from '../utils/e2e'; +import {assertHTML, ctrlOrCmd, focusEditor, html, initialize, insertCard, paste, pasteHtml, pasteLexical, pasteText} from '../utils/e2e'; import {expect, test} from '@playwright/test'; test.describe('Paste behaviour', async () => { @@ -307,6 +307,210 @@ test.describe('Paste behaviour', async () => { }); }); + test.describe('Quotes', function () { + test.describe('Lexical paste', function () { + test('keeps quote formatting when pasting text node', async function () { + const copiedLexical = {namespace: 'KoenigEditor', nodes: [{ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', + type: 'extended-text', + version: 1 + }]}; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteLexical(page, JSON.stringify(copiedLexical)); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + }); + + test('keeps quote formatting when pasting single paragraph', async function () { + const copiedLexical = {namespace: 'KoenigEditor', nodes: [{ + children: [{ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', + type: 'extended-text', + version: 1 + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }]}; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteLexical(page, JSON.stringify(copiedLexical)); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + }); + + test('keeps quote formatting when pasting single paragraph with trailing empty paragraph', async function () { + const copiedLexical = {namespace: 'KoenigEditor', nodes: [{ + children: [{ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', + type: 'extended-text', + version: 1 + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }, { + children: [], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }]}; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteLexical(page, JSON.stringify(copiedLexical)); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + }); + + test('handles paste of text nodes with links', async function () { + const copiedLexical = {namespace: 'KoenigEditor', nodes: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'For the full list of markdown references, check ', + type: 'extended-text', + version: 1 + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'ghost.org/changelog/markdown/', + type: 'extended-text', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'link', + version: 1, + rel: null, + target: null, + title: null, + url: 'https://ghost.org/changelog/markdown/' + }, + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: '.', + type: 'extended-text', + version: 1 + } + ]}; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteLexical(page, JSON.stringify(copiedLexical)); + + await assertHTML(page, html` +
+ For the full list of markdown references, check + + ghost.org/changelog/markdown/ + + . +
+ `); + }); + + test('handles paragraph paste onto non-empty quote', async function () { + const copiedLexical = {namespace: 'KoenigEditor', nodes: [{ + children: [{ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'There\'s a whole lot to discover in this editor. Let us help you settle in.', + type: 'extended-text', + version: 1 + }], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }]}; + + await focusEditor(page); + await page.keyboard.type('> '); + await page.keyboard.type('Some text '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteLexical(page, JSON.stringify(copiedLexical)); + + await assertHTML(page, html` +
+ + Some text There's a whole lot to discover in this editor. Let us help you settle in. + +
+ `); + }); + }); + + test.describe('HTML paste', function () { + test('keeps quote formatting when pasting text', async function () { + const copiedHtml = `Nam viverra blandit massa id vehicula.`; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +
+ Nam viverra blandit massa id vehicula. +
+ `); + }); + + test('keeps quote formatting when pasting single paragraph', async function () { + const copiedHtml = `

Nam viverra blandit massa id vehicula.


`; + + await focusEditor(page); + await page.keyboard.type('> '); + await expect(page.locator('[data-lexical-editor] blockquote')).toBeVisible(); + await pasteHtml(page, copiedHtml); + + await assertHTML(page, html` +
+ Nam viverra blandit massa id vehicula. +
+ `); + }); + }); + }); + test.describe('Office.com Word', function () { test('supports basic text formatting', async function () { const copiedHtml = fs.readFileSync('test/e2e/fixtures/paste/office-com-text-formats.html', 'utf8');