diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 27640aa4a..c745f78be 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -201,7 +201,7 @@ services: environment: # Don't touch it; this uses an internal docker network to # communicate with the frontend. - PENPOT_PUBLIC_URI: http://penpot-frontend + PENPOT_PUBLIC_URI: http://penpot-frontend:8080 ## Redis is used for the websockets notifications. PENPOT_REDIS_URI: redis://penpot-redis/0 diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 2c28f269f..1b2ca84ed 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -6,7 +6,7 @@ * Copyright (c) KALEIDOS INC */ -import { createInline } from "./Inline.js"; +import { createInline, isLikeInline } from "./Inline.js"; import { createEmptyParagraph, createParagraph, @@ -14,6 +14,31 @@ import { } from "./Paragraph.js"; import { isDisplayBlock, normalizeStyles } from "./Style.js"; +/** + * Returns if the content fragment should be treated as + * inline content and not a paragraphed one. + * + * @returns {boolean} + */ +function isContentFragmentFromDocumentInline(document) { + const nodeIterator = document.createNodeIterator( + document.documentElement, + NodeFilter.SHOW_ELEMENT, + ); + let currentNode = nodeIterator.nextNode(); + while (currentNode) { + if (["HTML", "HEAD", "BODY"].includes(currentNode.nodeName)) { + currentNode = nodeIterator.nextNode(); + continue; + } + + if (!isLikeInline(currentNode)) return false; + + currentNode = nodeIterator.nextNode(); + } + return true; +} + /** * Maps any HTML into a valid content DOM element. * @@ -58,6 +83,13 @@ export function mapContentFragmentFromDocument(document, root, styleDefaults) { } fragment.appendChild(currentParagraph); + if (fragment.children.length === 1) { + const isContentInline = isContentFragmentFromDocumentInline(document); + if (isContentInline) { + currentParagraph.dataset.inline = "force"; + } + } + return fragment; } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 0329de943..4f8e49ca9 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -1034,7 +1034,23 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ insertPaste(fragment) { - const numParagraphs = fragment.children.length; + if (fragment.children.length === 1 + && fragment.firstElementChild?.dataset?.inline === "force" + ) { + if (this.isInlineStart) { + this.focusInline.before(...fragment.firstElementChild.children) + } else if (this.isInlineEnd) { + this.focusInline.after(...fragment.firstElementChild.children); + } else { + const newInline = splitInline( + this.focusInline, + this.focusOffset + ) + this.focusInline.after(...fragment.firstElementChild.children, newInline) + } + return; + } + if (this.isParagraphStart) { this.focusParagraph.before(fragment); } else if (this.isParagraphEnd) { @@ -1055,7 +1071,6 @@ export class SelectionController extends EventTarget { * @param {DocumentFragment} fragment */ replaceWithPaste(fragment) { - const numParagraphs = fragment.children.length; this.removeSelected(); this.insertPaste(fragment); } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 786c9a18d..c44989f69 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -246,6 +246,251 @@ describe("SelectionController", () => { ); }); + test("`insertPaste` should insert a paragraph from a pasted fragment (at start)", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText(", World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, 0); + const paragraph = createParagraph([createInline(new Text("Hello"))]); + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello", + ); + expect( + textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, + ).toBe(", World!"); + }); + + test("`insertPaste` should insert a paragraph from a pasted fragment (at middle)", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Lorem dolor"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); + const paragraph = createParagraph([createInline(new Text("ipsum "))]); + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Lorem ipsum dolor"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Lorem ", + ); + expect(textEditorMock.root.children.item(1).firstChild.firstChild.nodeValue).toBe( + "ipsum ", + ); + expect(textEditorMock.root.lastChild.firstChild.firstChild.nodeValue).toBe( + "dolor", + ); + }); + + test("`insertPaste` should insert a paragraph from a pasted fragment (at end)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello".length, + ); + const paragraph = createParagraph([createInline(new Text(", World!"))]); + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello", + ); + expect( + textEditorMock.root.lastChild.firstChild.firstChild.nodeValue, + ).toBe(", World!"); + }); + + test("`insertPaste` should insert an inline from a pasted fragment (at start)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText(", World!"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + 0, + ); + const paragraph = createParagraph([createInline(new Text("Hello"))]); + paragraph.dataset.inline = "force"; + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello", + ); + expect( + textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue, + ).toBe(", World!"); + }); + + test("`insertPaste` should insert an inline from a pasted fragment (at middle)", () => { + const textEditorMock = + TextEditorMock.createTextEditorMockWithText("Lorem dolor"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus(selection, textEditorMock, root.firstChild.firstChild.firstChild, "Lorem ".length); + const paragraph = createParagraph([createInline(new Text("ipsum "))]); + paragraph.dataset.inline = "force"; + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Lorem ipsum dolor"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Lorem ", + ); + expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( + "ipsum ", + ); + expect( + textEditorMock.root.firstChild.children.item(2).firstChild.nodeValue, + ).toBe("dolor"); + }); + + test("`insertPaste` should insert an inline from a pasted fragment (at end)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello"); + const root = textEditorMock.root; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + focus( + selection, + textEditorMock, + root.firstChild.firstChild.firstChild, + "Hello".length, + ); + const paragraph = createParagraph([ + createInline(new Text(", World!")) + ]); + paragraph.dataset.inline = "force"; + const fragment = document.createDocumentFragment(); + fragment.append(paragraph); + + selectionController.insertPaste(fragment); + expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.dataset.itype).toBe("root"); + expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement); + expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph"); + expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf( + HTMLSpanElement, + ); + expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe( + "inline", + ); + expect(textEditorMock.root.textContent).toBe("Hello, World!"); + expect(textEditorMock.root.firstChild.firstChild.firstChild).toBeInstanceOf( + Text, + ); + expect(textEditorMock.root.firstChild.firstChild.firstChild.nodeValue).toBe( + "Hello", + ); + expect(textEditorMock.root.firstChild.children.item(1).firstChild.nodeValue).toBe( + ", World!", + ); + }); + test("`removeBackwardText` should remove text in backward direction (backspace)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithText("Hello, World!");