diff --git a/lang/en/editor.php b/lang/en/editor.php index 752c6f3f7..0d250e9a7 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -48,6 +48,7 @@ return [ 'superscript' => 'Superscript', 'subscript' => 'Subscript', 'text_color' => 'Text color', + 'highlight_color' => 'Highlight color', 'custom_color' => 'Custom color', 'remove_color' => 'Remove color', 'background_color' => 'Background color', diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index f572f9de5..e01b4e8f4 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -19,6 +19,7 @@ import {contextToolbars, getBasicEditorToolbar, getMainEditorFullToolbar} from " import {modals} from "./ui/defaults/modals"; import {CodeBlockDecorator} from "./ui/decorators/code-block"; import {DiagramDecorator} from "./ui/decorators/diagram"; +import {registerMouseHandling} from "./services/mouse-handling"; const theme = { text: { @@ -51,6 +52,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerHistory(editor, createEmptyHistoryState(), 300), registerShortcuts(context), registerKeyboardHandling(context), + registerMouseHandling(context), registerTableResizer(editor, context.scrollDOM), registerTableSelectionHandler(editor), registerTaskListHandler(editor, context.editorDOM), diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e18ef9756..fd87877ee 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -848,4 +848,20 @@ export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: dispatchKeydownEventForNode(node, editor, key); } }); +} + +export function dispatchEditorMouseClick(editor: LexicalEditor, clientX: number, clientY: number) { + const dom = editor.getRootElement(); + if (!dom) { + return; + } + + const event = new MouseEvent('click', { + clientX: clientX, + clientY: clientY, + bubbles: true, + cancelable: true, + }); + dom?.dispatchEvent(event); + editor.commitUpdates(); } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index e5064121a..b466ee34a 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -146,7 +146,7 @@ describe('HTML', () => { }); expect(html).toBe( - '
Hello
World
', + 'Hello
\nWorld
', ); }); diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 5018e10b4..de5e53bb8 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -85,7 +85,18 @@ export function $generateHtmlFromNodes( $appendNodesToHTML(editor, topLevelNode, container, selection); } - return container.innerHTML; + const nodeCode = []; + for (const node of container.childNodes) { + if ("outerHTML" in node) { + nodeCode.push(node.outerHTML) + } else { + const wrap = document.createElement('div'); + wrap.appendChild(node.cloneNode(true)); + nodeCode.push(wrap.innerHTML); + } + } + + return nodeCode.join('\n'); } function $appendNodesToHTML( diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts index 0f3513682..1103f73d3 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalAutoLinkNode.test.ts @@ -273,18 +273,6 @@ describe('LexicalAutoAutoLinkNode tests', () => { }); }); - test('AutoLinkNode.createDOM() sanitizes javascript: URLs', async () => { - const {editor} = testEnv; - - await editor.update(() => { - // eslint-disable-next-line no-script-url - const autoLinkNode = new AutoLinkNode('javascript:alert(0)'); - expect(autoLinkNode.createDOM(editorConfig).outerHTML).toBe( - '', - ); - }); - }); - test('AutoLinkNode.updateDOM()', async () => { const {editor} = testEnv; diff --git a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts index 1aff91863..c50450302 100644 --- a/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts +++ b/resources/js/wysiwyg/lexical/link/__tests__/unit/LexicalLinkNode.test.ts @@ -218,18 +218,6 @@ describe('LexicalLinkNode tests', () => { }); }); - test('LinkNode.createDOM() sanitizes javascript: URLs', async () => { - const {editor} = testEnv; - - await editor.update(() => { - // eslint-disable-next-line no-script-url - const linkNode = new LinkNode('javascript:alert(0)'); - expect(linkNode.createDOM(editorConfig).outerHTML).toBe( - '', - ); - }); - }); - test('LinkNode.updateDOM()', async () => { const {editor} = testEnv; diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts index 884fe9153..336bb1546 100644 --- a/resources/js/wysiwyg/lexical/link/index.ts +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -48,14 +48,6 @@ export type SerializedLinkNode = Spread< type LinkHTMLElementType = HTMLAnchorElement | HTMLSpanElement; -const SUPPORTED_URL_PROTOCOLS = new Set([ - 'http:', - 'https:', - 'mailto:', - 'sms:', - 'tel:', -]); - /** @noInheritDoc */ export class LinkNode extends ElementNode { /** @internal */ @@ -90,7 +82,7 @@ export class LinkNode extends ElementNode { createDOM(config: EditorConfig): LinkHTMLElementType { const element = document.createElement('a'); - element.href = this.sanitizeUrl(this.__url); + element.href = this.__url; if (this.__target !== null) { element.target = this.__target; } @@ -166,19 +158,6 @@ export class LinkNode extends ElementNode { return node; } - sanitizeUrl(url: string): string { - try { - const parsedUrl = new URL(url); - // eslint-disable-next-line no-script-url - if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { - return 'about:blank'; - } - } catch { - return url; - } - return url; - } - exportJSON(): SerializedLinkNode | SerializedAutoLinkNode { return { ...super.exportJSON(), diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 1fc6b42bb..1c9d7ecf6 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -353,10 +353,17 @@ export function $convertTableCellNodeElement( const hasUnderlineTextDecoration = textDecoration.includes('underline'); if (domNode instanceof HTMLElement) { - tableCellNode.setStyles(extractStyleMapFromElement(domNode)); + const styleMap = extractStyleMapFromElement(domNode); + styleMap.delete('background-color'); + tableCellNode.setStyles(styleMap); tableCellNode.setAlignment(extractAlignmentFromElement(domNode)); } + const background = style.backgroundColor || null; + if (background) { + tableCellNode.setBackgroundColor(background); + } + return { after: (childLexicalNodes) => { if (childLexicalNodes.length === 0) { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts index 54cd8b54f..6a415d008 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -38,7 +38,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph in between two text nodes', expectedHtml: - 'Hello
world
', + 'Hello
\nworld
', initialHtml: 'Helloworld
', splitOffset: 1, splitPath: [0], @@ -46,7 +46,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph before the first text node', expectedHtml: - 'Helloworld
', + 'Helloworld
', initialHtml: 'Helloworld
', splitOffset: 0, splitPath: [0], @@ -54,7 +54,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split paragraph after the last text node', expectedHtml: - 'Helloworld
Helloworld
\nHelloworld
', splitOffset: 2, // Any offset that is higher than children size splitPath: [0], @@ -62,7 +62,7 @@ describe('LexicalUtils#splitNode', () => { { _: 'split list items between two text nodes', expectedHtml: - 'Hello
world
', + 'Hello
\nworld
', initialHtml: 'Helloworld
', selectionOffset: 5, // Selection on text node after "Hello" world selectionPath: [0, 0], @@ -57,8 +57,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { 'Hello world
' + - 'Hello world
\n' + + 'Hello world
', selectionOffset: 12, // Selection on text node after "Hello" world @@ -92,8 +92,8 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert in the beginning of paragraph', expectedHtml: - 'Hello world
', initialHtml: 'Hello world
', selectionOffset: 0, // Selection on text node after "Hello" world @@ -102,9 +102,9 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root start', expectedHtml: - 'Before
' + + 'Before
\n' + 'After
', initialHtml: 'Before
' + - 'Before
\n' + + 'After
', initialHtml: 'Before
After
', selectionOffset: 1, @@ -126,7 +126,7 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { { _: 'insert with selection on root end', expectedHtml: - 'Before
' + + 'Before
\n' + 'Before
', selectionOffset: 1, diff --git a/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts new file mode 100644 index 000000000..a3da35206 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/mouse-handling.test.ts @@ -0,0 +1,51 @@ +import { + createTestContext, destroyFromContext, dispatchEditorMouseClick, +} from "lexical/__tests__/utils"; +import { + $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {registerMouseHandling} from "../mouse-handling"; +import {$createTableNode, TableNode} from "@lexical/table"; + +describe('Mouse-handling service tests', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerMouseHandling(context); + }); + + afterEach(() => { + destroyFromContext(context); + }); + + test('Click below last table inserts new empty paragraph', () => { + let tableNode!: TableNode; + let lastRootChild!: LexicalNode|null; + + editor.updateAndCommit(() => { + tableNode = $createTableNode(); + $getRoot().append(tableNode); + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(TableNode); + + const tableDOM = editor.getElementByKey(tableNode.getKey()); + const rect = tableDOM?.getBoundingClientRect(); + dispatchEditorMouseClick(editor, 0, (rect?.bottom || 0) + 1) + + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/mouse-handling.ts b/resources/js/wysiwyg/services/mouse-handling.ts new file mode 100644 index 000000000..058efc8d2 --- /dev/null +++ b/resources/js/wysiwyg/services/mouse-handling.ts @@ -0,0 +1,63 @@ +import {EditorUiContext} from "../ui/framework/core"; +import { + $createParagraphNode, $getRoot, + $getSelection, + $isDecoratorNode, CLICK_COMMAND, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, KEY_TAB_COMMAND, + LexicalEditor, + LexicalNode +} from "lexical"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; +import {getLastSelection} from "../utils/selection"; +import {$getNearestNodeBlockParent, $getParentOfType, $selectOrCreateAdjacent} from "../utils/nodes"; +import {$setInsetForSelection} from "../utils/lists"; +import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$isDiagramNode} from "../utils/diagrams"; +import {$isTableNode} from "@lexical/table"; + +function isHardToEscapeNode(node: LexicalNode): boolean { + return $isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node) || $isTableNode(node); +} + +function insertBelowLastNode(context: EditorUiContext, event: MouseEvent): boolean { + const lastNode = $getRoot().getLastChild(); + if (!lastNode || !isHardToEscapeNode(lastNode)) { + return false; + } + + const lastNodeDom = context.editor.getElementByKey(lastNode.getKey()); + if (!lastNodeDom) { + return false; + } + + const nodeBounds = lastNodeDom.getBoundingClientRect(); + const isClickBelow = event.clientY > nodeBounds.bottom; + if (isClickBelow) { + context.editor.update(() => { + const newNode = $createParagraphNode(); + $getRoot().append(newNode); + newNode.select(); + }); + return true; + } + + return false; +} + + +export function registerMouseHandling(context: EditorUiContext): () => void { + const unregisterClick = context.editor.registerCommand(CLICK_COMMAND, (event): boolean => { + insertBelowLastNode(context, event); + return false; + }, COMMAND_PRIORITY_LOW); + + + return () => { + unregisterClick(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 0384a3bf1..ead4c38d4 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -13,14 +13,16 @@ import {$showLinkForm} from "../ui/defaults/forms/objects"; import {showLinkSelector} from "../utils/links"; import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; -function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { - toggleSelectionAsHeading(editor, tag); +function headerHandler(context: EditorUiContext, tag: HeadingTagType): boolean { + toggleSelectionAsHeading(context.editor, tag); + context.manager.triggerFutureStateRefresh(); return true; } function wrapFormatAction(formatAction: (editor: LexicalEditor) => any): ShortcutAction { - return (editor: LexicalEditor) => { + return (editor: LexicalEditor, context: EditorUiContext) => { formatAction(editor); + context.manager.triggerFutureStateRefresh(); return true; }; } @@ -45,10 +47,10 @@ const actionsByKeys: Record